From 8f1607e01022485124f7f06494955562bd29af2c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Sep 2017 17:47:42 -0400 Subject: [PATCH 01/24] Initial work on reports --- .gitignore | 2 + netbox/extras/constants.py | 14 +++ .../extras/management/commands/runreport.py | 47 ++++++++ netbox/extras/reports.py | 100 ++++++++++++++++++ netbox/reports/__init__.py | 0 5 files changed, 163 insertions(+) create mode 100644 netbox/extras/management/commands/runreport.py create mode 100644 netbox/extras/reports.py create mode 100644 netbox/reports/__init__.py diff --git a/.gitignore b/.gitignore index 2f957c678..b33d46a40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/reports/* +!/netbox/reports/__init__.py /netbox/static .idea /*.sh diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 99eb779c6..fc5786a64 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -62,3 +62,17 @@ ACTION_CHOICES = ( (ACTION_DELETE, 'deleted'), (ACTION_BULK_DELETE, 'bulk deleted'), ) + +# 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', +} diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py new file mode 100644 index 000000000..d228771d2 --- /dev/null +++ b/netbox/extras/management/commands/runreport.py @@ -0,0 +1,47 @@ +from __future__ import unicode_literals +import importlib +import inspect + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from extras.reports import Report + + +class Command(BaseCommand): + help = "Run a report to validate data in NetBox" + + def add_arguments(self, parser): + parser.add_argument('reports', nargs='+', help="Report(s) to run") + # parser.add_argument('--verbose', action='store_true', default=False, help="Print all logs") + + def handle(self, *args, **options): + + # Gather all reports to be run + reports = [] + for module_name in options['reports']: + try: + report_module = importlib.import_module('reports.report_{}'.format(module_name)) + except ImportError: + self.stdout.write( + "Report '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the reports " + "directory.".format(module_name, module_name) + ) + return + for name, cls in inspect.getmembers(report_module, inspect.isclass): + if cls in Report.__subclasses__(): + reports.append((name, cls)) + + # Run reports + for name, report in reports: + self.stdout.write("[{:%H:%M:%S}] Running report {}...".format(timezone.now(), name)) + report = report() + report.run() + status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') + self.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), name, status)) + for test_name, attrs in report.results.items(): + self.stdout.write(" {}: {} success, {} info, {} warning, {} failed".format( + test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] + )) + + self.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now())) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py new file mode 100644 index 000000000..22169c6b8 --- /dev/null +++ b/netbox/extras/reports.py @@ -0,0 +1,100 @@ +from collections import OrderedDict + +from django.utils import timezone + +from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING + + +class Report(object): + """ + NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each + report must have one or more test methods named `test_*`. + + The `results` attribute of a completed report will take the following form: + + { + 'test_bar': { + 'failures': 42, + 'log': [ + (, , , ), + ... + ] + }, + 'test_foo': { + 'failures': 0, + 'log': [ + (, , , ), + ... + ] + } + } + """ + results = OrderedDict() + active_test = None + failed = False + + def __init__(self): + + # Compile test methods and initialize results skeleton + test_methods = [] + for method in dir(self): + if method.startswith('test_') and callable(getattr(self, method)): + test_methods.append(method) + self.results[method] = OrderedDict([ + ('success', 0), + ('info', 0), + ('warning', 0), + ('failed', 0), + ('log', []), + ]) + if not test_methods: + raise Exception("A report must contain at least one test method.") + self.test_methods = test_methods + + def _log(self, obj, message, level=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: + raise Exception("Unknown logging level: {}".format(level)) + logline = [timezone.now(), level, obj, message] + self.results[self.active_test]['log'].append(logline) + + def log_success(self, obj, message=None): + """ + Record a successful test against an object. Logging a message is optional. + """ + if message: + self._log(obj, message, level=LOG_SUCCESS) + self.results[self.active_test]['success'] += 1 + + def log_info(self, obj, message): + """ + Log an informational message. + """ + self._log(obj, message, level=LOG_INFO) + self.results[self.active_test]['info'] += 1 + + def log_warning(self, obj, message): + """ + Log a warning. + """ + self._log(obj, message, level=LOG_WARNING) + self.results[self.active_test]['warning'] += 1 + + def log_failure(self, obj, message): + """ + Log a failure. Calling this method will automatically mark the report as failed. + """ + self._log(obj, message, level=LOG_FAILURE) + self.results[self.active_test]['failed'] += 1 + self.failed = True + + def run(self): + """ + Run the report. Each test method will be executed in order. + """ + for method_name in self.test_methods: + self.active_test = method_name + test_method = getattr(self, method_name) + test_method() diff --git a/netbox/reports/__init__.py b/netbox/reports/__init__.py new file mode 100644 index 000000000..e69de29bb From 16d1f9aca8a35553461e25faa5d67b10a17266e9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Sep 2017 13:49:04 -0400 Subject: [PATCH 02/24] Tweaked report run logic --- .../extras/management/commands/runreport.py | 33 +++++++++++++------ netbox/extras/reports.py | 11 ++++--- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index d228771d2..43f027842 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -20,26 +20,39 @@ class Command(BaseCommand): # Gather all reports to be run reports = [] for module_name in options['reports']: + + # Split the report name off if one has been provided. + report_name = None + if '.' in module_name: + module_name, report_name = module_name.split('.', 1) + + # Import the report module try: report_module = importlib.import_module('reports.report_{}'.format(module_name)) except ImportError: self.stdout.write( - "Report '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the reports " - "directory.".format(module_name, module_name) + "Report module '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the " + "reports directory.".format(module_name, module_name) ) return - for name, cls in inspect.getmembers(report_module, inspect.isclass): - if cls in Report.__subclasses__(): - reports.append((name, cls)) + + # If the name of a particular report has been given, run that. Otherwise, run all reports in the module. + if report_name is not None: + report_cls = getattr(report_module, report_name) + reports = [(report_name, report_cls)] + else: + for name, report_cls in inspect.getmembers(report_module, inspect.isclass): + if report_cls in Report.__subclasses__(): + reports.append((name, report_cls)) # Run reports - for name, report in reports: - self.stdout.write("[{:%H:%M:%S}] Running report {}...".format(timezone.now(), name)) - report = report() - report.run() + for name, report_cls in reports: + self.stdout.write("[{:%H:%M:%S}] Running {}...".format(timezone.now(), name)) + report = report_cls() + results = report.run() status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') self.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), name, status)) - for test_name, attrs in report.results.items(): + for test_name, attrs in results.items(): self.stdout.write(" {}: {} success, {} info, {} warning, {} failed".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] )) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 22169c6b8..e65ef46a2 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -29,12 +29,13 @@ class Report(object): } } """ - results = OrderedDict() - active_test = None - failed = False def __init__(self): + self.results = OrderedDict() + self.active_test = None + self.failed = False + # Compile test methods and initialize results skeleton test_methods = [] for method in dir(self): @@ -92,9 +93,11 @@ class Report(object): def run(self): """ - Run the report. Each test method will be executed in order. + Run the report and return its results. Each test method will be executed in order. """ for method_name in self.test_methods: self.active_test = method_name test_method = getattr(self, method_name) test_method() + + return self.results From b5ab498e75cbdc29b2fec92c5bcd4e3786bccc44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Sep 2017 16:32:05 -0400 Subject: [PATCH 03/24] Initial work on reports API --- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 27 ++++++- .../extras/management/commands/runreport.py | 72 ++++++++----------- netbox/extras/migrations/0008_reports.py | 33 +++++++++ netbox/extras/models.py | 21 ++++++ netbox/extras/reports.py | 34 +++++++++ netbox/extras/urls.py | 3 + netbox/extras/views.py | 32 ++++++++- netbox/templates/extras/report_list.html | 18 +++++ 9 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 netbox/extras/migrations/0008_reports.py create mode 100644 netbox/templates/extras/report_list.html diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index c5268318c..da76e67bd 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -28,6 +28,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet) # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) +# Reports +router.register(r'reports', views.ReportViewSet, base_name='report') + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 37112f2c6..bf54a3220 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals +from collections import OrderedDict from rest_framework.decorators import detail_route -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse @@ -9,6 +11,7 @@ from django.shortcuts import get_object_or_404 from extras import filters from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction +from extras.reports import get_reports from utilities.api import WritableSerializerMixin from . import serializers @@ -88,6 +91,28 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): write_serializer_class = serializers.WritableImageAttachmentSerializer +class ReportViewSet(ViewSet): + _ignore_model_permissions = True + exclude_from_schema = True + + def list(self, request): + + ret_list = [] + for module_name, reports in get_reports(): + for report_name, report_cls in reports: + report = OrderedDict(( + ('module', module_name), + ('name', report_name), + ('description', report_cls.description), + ('test_methods', report_cls().test_methods), + )) + ret_list.append(report) + + return Response(ret_list) + + + + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 43f027842..6c63eed40 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -1,11 +1,10 @@ from __future__ import unicode_literals -import importlib -import inspect -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.utils import timezone -from extras.reports import Report +from extras.models import ReportResult +from extras.reports import get_reports class Command(BaseCommand): @@ -18,43 +17,34 @@ class Command(BaseCommand): def handle(self, *args, **options): # Gather all reports to be run - reports = [] - for module_name in options['reports']: - - # Split the report name off if one has been provided. - report_name = None - if '.' in module_name: - module_name, report_name = module_name.split('.', 1) - - # Import the report module - try: - report_module = importlib.import_module('reports.report_{}'.format(module_name)) - except ImportError: - self.stdout.write( - "Report module '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the " - "reports directory.".format(module_name, module_name) - ) - return - - # If the name of a particular report has been given, run that. Otherwise, run all reports in the module. - if report_name is not None: - report_cls = getattr(report_module, report_name) - reports = [(report_name, report_cls)] - else: - for name, report_cls in inspect.getmembers(report_module, inspect.isclass): - if report_cls in Report.__subclasses__(): - reports.append((name, report_cls)) + reports = get_reports() # Run reports - for name, report_cls in reports: - self.stdout.write("[{:%H:%M:%S}] Running {}...".format(timezone.now(), name)) - report = report_cls() - results = report.run() - status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - self.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), name, status)) - for test_name, attrs in results.items(): - self.stdout.write(" {}: {} success, {} info, {} warning, {} failed".format( - test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] - )) + for module_name, report in reports: + for report_name, report_cls in report: + report_name_full = '{}.{}'.format(module_name, report_name) + if module_name in options['reports'] or report_name_full in options['reports']: - self.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now())) + # Run the report + self.stdout.write( + "[{:%H:%M:%S}] Running {}.{}...".format(timezone.now(), module_name, report_name) + ) + report = report_cls() + results = report.run() + + # Report on success/failure + status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') + for test_name, attrs in results.items(): + self.stdout.write( + "\t{}: {} success, {} info, {} warning, {} failed".format( + test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] + ) + ) + self.stdout.write( + "[{:%H:%M:%S}] {}.{}: {}".format(timezone.now(), module_name, report_name, status) + ) + + # Wrap things up + self.stdout.write( + "[{:%H:%M:%S}] Finished".format(timezone.now()) + ) diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py new file mode 100644 index 000000000..77f4c6501 --- /dev/null +++ b/netbox/extras/migrations/0008_reports.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-21 20:31 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0007_unicode_literals'), + ] + + operations = [ + migrations.CreateModel( + name='ReportResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_created=True)), + ('report', models.CharField(max_length=255, unique=True)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['report'], + 'permissions': (('run_report', 'Run a report and save the results'),), + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 4afc3afcf..413f9994d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -6,6 +6,7 @@ import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -388,6 +389,26 @@ class ImageAttachment(models.Model): return None +# +# Report results +# + +class ReportResult(models.Model): + """ + This model stores the results from running a user-defined report. + """ + report = models.CharField(max_length=255, unique=True) + created = models.DateTimeField(auto_created=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + data = JSONField() + + class Meta: + ordering = ['report'] + permissions = ( + ('run_report', 'Run a report and save the results'), + ) + + # # User actions # diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index e65ef46a2..1f91930a6 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,8 +1,41 @@ from collections import OrderedDict +import importlib +import inspect +import pkgutil from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING +import reports as user_reports + + +def is_report(obj): + """ + Returns True if the given object is a Report. + """ + if obj in Report.__subclasses__(): + return True + return False + + +def get_reports(): + """ + Compile a list of all reports available across all modules in the reports path. + """ + module_list = [] + + # Iterate through all modules within the reports path + for importer, module_name, is_pkg in pkgutil.walk_packages(user_reports.__path__): + module = importlib.import_module('reports.{}'.format(module_name)) + report_list = [] + + # Iterate through all Report classes within the module + for report_name, report_cls in inspect.getmembers(module, is_report): + report_list.append((report_name, report_cls)) + + module_list.append((module_name, report_list)) + + return module_list class Report(object): @@ -29,6 +62,7 @@ class Report(object): } } """ + description = None def __init__(self): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f980158e8..3828d696e 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -12,4 +12,7 @@ urlpatterns = [ url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Reports + url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 881525237..4692482ee 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,13 +1,21 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.generic import View +from . import reports from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm -from .models import ImageAttachment +from .models import ImageAttachment, ReportResult +from .reports import get_reports +# +# Image attachments +# + class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_imageattachment' model = ImageAttachment @@ -30,3 +38,23 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() + + +# +# Reports +# + +class ReportListView(View): + """ + Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. + """ + + def get(self, request): + + reports = get_reports() + results = {r.name: r for r in ReportResult.objects.all()} + + return render(request, 'extras/report_list.html', { + 'reports': reports, + 'results': results, + }) diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html new file mode 100644 index 000000000..04602869f --- /dev/null +++ b/netbox/templates/extras/report_list.html @@ -0,0 +1,18 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

Reports

+
+
+ {% for module, report_list in reports %} +

{{ module|bettertitle }}

+
    + {% for name, cls in report_list %} +
  • {{ name }}
  • + {% endfor %} +
+ {% endfor %} +
+
+{% endblock %} From 79fdf641c007a289110af3a2092f5be0e2121f8c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Sep 2017 12:11:10 -0400 Subject: [PATCH 04/24] Implemented rough UI for accessing report results --- .../extras/management/commands/runreport.py | 4 ++ netbox/extras/migrations/0008_reports.py | 5 +- netbox/extras/models.py | 3 +- netbox/extras/reports.py | 19 +++++-- netbox/extras/views.py | 19 +++++-- netbox/templates/extras/report_list.html | 53 ++++++++++++++++--- 6 files changed, 85 insertions(+), 18 deletions(-) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 6c63eed40..acffdc54d 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -32,6 +32,10 @@ class Command(BaseCommand): report = report_cls() results = report.run() + # Record the results + ReportResult.objects.filter(report=report_name_full).delete() + ReportResult(report=report_name_full, failed=report.failed, data=results).save() + # Report on success/failure status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') for test_name, attrs in results.items(): diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index 77f4c6501..0cfe48ba5 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-09-21 20:31 +# Generated by Django 1.11.4 on 2017-09-22 15:21 from __future__ import unicode_literals from django.conf import settings @@ -20,8 +20,9 @@ class Migration(migrations.Migration): name='ReportResult', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_created=True)), ('report', models.CharField(max_length=255, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('failed', models.BooleanField()), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 413f9994d..d1eee12cb 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -398,8 +398,9 @@ class ReportResult(models.Model): This model stores the results from running a user-defined report. """ report = models.CharField(max_length=255, unique=True) - created = models.DateTimeField(auto_created=True) + created = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + failed = models.BooleanField() data = JSONField() class Meta: diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 1f91930a6..99f42d9b8 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -20,7 +20,18 @@ def is_report(obj): def get_reports(): """ - Compile a list of all reports available across all modules in the reports path. + Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: + + [ + (module_name, ( + (report_name, report_class), + (report_name, report_class) + ), + (module_name, ( + (report_name, report_class), + (report_name, report_class) + ) + ] """ module_list = [] @@ -30,8 +41,8 @@ def get_reports(): report_list = [] # Iterate through all Report classes within the module - for report_name, report_cls in inspect.getmembers(module, is_report): - report_list.append((report_name, report_cls)) + for report_name, report_class in inspect.getmembers(module, is_report): + report_list.append((report_name, report_class)) module_list.append((module_name, report_list)) @@ -92,7 +103,7 @@ class Report(object): """ if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) - logline = [timezone.now(), level, obj, message] + logline = [timezone.now().isoformat(), level, str(obj), message] self.results[self.active_test]['log'].append(logline) def log_success(self, obj, message=None): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4692482ee..d39d8c17c 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals +from collections import OrderedDict from django.contrib.auth.mixins import PermissionRequiredMixin -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views.generic import View @@ -52,9 +53,19 @@ class ReportListView(View): def get(self, request): reports = get_reports() - results = {r.name: r for r in ReportResult.objects.all()} + results = {r.report: r for r in ReportResult.objects.all()} + + foo = [] + for module, report_list in reports: + module_reports = [] + for report_name, report_class in report_list: + module_reports.append({ + 'name': report_name, + 'description': report_class.description, + 'results': results.get('{}.{}'.format(module, report_name), None) + }) + foo.append((module, module_reports)) return render(request, 'extras/report_list.html', { - 'reports': reports, - 'results': results, + 'reports': foo, }) diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 04602869f..1b987c520 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -5,13 +5,52 @@

Reports

- {% for module, report_list in reports %} -

{{ module|bettertitle }}

-
    - {% for name, cls in report_list %} -
  • {{ name }}
  • - {% endfor %} -
+ {% for module, module_reports in reports %} +

{{ module|bettertitle }}

+ + + + + + + + + + + {% for report in module_reports %} + + + + {% if report.results %} + + + {% else %} + + + {% endif %} + + {% for method, stats in report.results.data.items %} + + + + {% endfor %} + {% endfor %} + +
NameDescriptionLast RunStatus
{{ report.name }}{{ report.description|default:"" }}{{ report.results.created }} + {% if report.results.failed %} + + {% else %} + + {% endif %} + Never
+
+ + + + +
+ {{ method }} +
{% endfor %}
From 88c57d002d6d499e5f801c4d068c3d6695565b23 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 16:22:50 -0400 Subject: [PATCH 05/24] Added navigation panel --- netbox/templates/extras/inc/report_label.html | 7 ++++ netbox/templates/extras/report_list.html | 35 ++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 netbox/templates/extras/inc/report_label.html diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html new file mode 100644 index 000000000..8e2a2a5b5 --- /dev/null +++ b/netbox/templates/extras/inc/report_label.html @@ -0,0 +1,7 @@ +{% if report.results.failed %} + +{% elif report.results %} + +{% else %} + +{% endif %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 1b987c520..492da8b75 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -2,12 +2,12 @@ {% load helpers %} {% block content %} -

Reports

+

{% block title %}Reports{% endblock %}

{% for module, module_reports in reports %} -

{{ module|bettertitle }}

- +

{{ module|bettertitle }}

+
@@ -19,25 +19,18 @@ {% for report in module_reports %} - + {% if report.results %} - {% else %} - {% endif %} + {% for method, stats in report.results.data.items %} - + {% endfor %} {% endfor %} @@ -53,5 +47,20 @@
Name
{{ report.name }}{{ report.name }} {{ report.description|default:"" }}{{ report.results.created }} - {% if report.results.failed %} - - {% else %} - - {% endif %} - Never{% include 'extras/inc/report_label.html' %}
+
@@ -46,6 +39,7 @@
{{ method }}
{% endfor %}
+
+ +
{% endblock %} From d35a2b0faab897998bde0510f597f433c181700c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 17:27:58 -0400 Subject: [PATCH 06/24] Extended reports API --- netbox/extras/api/serializers.py | 32 +++++++- netbox/extras/api/views.py | 78 +++++++++++++++---- .../extras/management/commands/runreport.py | 6 +- netbox/extras/reports.py | 8 ++ 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0eeab49ec..34e4312ac 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site from extras.models import ( - ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, + ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, ReportResult, TopologyMap, UserAction, ) from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer @@ -127,6 +127,36 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer): return data +# +# Reports +# + +class ReportResultSerializer(serializers.ModelSerializer): + + class Meta: + model = ReportResult + fields = ['created', 'user', 'failed', 'data'] + + +class NestedReportResultSerializer(serializers.ModelSerializer): + + class Meta: + model = ReportResult + fields = ['created', 'user', 'failed'] + + +class ReportSerializer(serializers.Serializer): + 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() + + +class ReportDetailSerializer(ReportSerializer): + result = ReportResultSerializer() + + # # User actions # diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index bf54a3220..27a806155 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,17 +1,16 @@ from __future__ import unicode_literals -from collections import OrderedDict from rest_framework.decorators import detail_route from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet from django.contrib.contenttypes.models import ContentType -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction -from extras.reports import get_reports +from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.reports import get_report, get_reports from utilities.api import WritableSerializerMixin from . import serializers @@ -94,23 +93,76 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): class ReportViewSet(ViewSet): _ignore_model_permissions = True exclude_from_schema = True + lookup_value_regex = '[^/]+' # Allow dots def list(self, request): - ret_list = [] + # Compile all reports + report_list = [] for module_name, reports in get_reports(): for report_name, report_cls in reports: - report = OrderedDict(( - ('module', module_name), - ('name', report_name), - ('description', report_cls.description), - ('test_methods', report_cls().test_methods), - )) - ret_list.append(report) + data = { + 'module': module_name, + 'name': report_name, + 'description': report_cls.description, + 'test_methods': report_cls().test_methods, + 'result': None, + } + try: + result = ReportResult.objects.defer('data').get(report='{}.{}'.format(module_name, report_name)) + data['result'] = result + except ReportResult.DoesNotExist: + pass + report_list.append(data) - return Response(ret_list) + serializer = serializers.ReportSerializer(report_list, many=True, context={'request': request}) + return Response(serializer.data) + def retrieve(self, request, pk): + + # Retrieve report by . + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + report_cls = get_report(module_name, report_name) + data = { + 'module': module_name, + 'name': report_name, + 'description': report_cls.description, + 'test_methods': report_cls().test_methods, + 'result': None, + } + + # Attach report result + try: + result = ReportResult.objects.get(report='{}.{}'.format(module_name, report_name)) + data['result'] = result + except ReportResult.DoesNotExist: + pass + + serializer = serializers.ReportDetailSerializer(data) + + return Response(serializer.data) + + @detail_route() + def run(self, request, pk): + + # Retrieve report by . + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + report_cls = get_report(module_name, report_name) + + # Run the report + report = report_cls() + result = report.run() + + # Save the ReportResult + ReportResult.objects.filter(report=pk).delete() + ReportResult(report=pk, failed=report.failed, data=result).save() + + return Response('Report completed.') class RecentActivityViewSet(ReadOnlyModelViewSet): diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index acffdc54d..70893b934 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -30,15 +30,15 @@ class Command(BaseCommand): "[{:%H:%M:%S}] Running {}.{}...".format(timezone.now(), module_name, report_name) ) report = report_cls() - results = report.run() + result = report.run() # Record the results ReportResult.objects.filter(report=report_name_full).delete() - ReportResult(report=report_name_full, failed=report.failed, data=results).save() + ReportResult(report=report_name_full, failed=report.failed, data=result).save() # Report on success/failure status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - for test_name, attrs in results.items(): + for test_name, attrs in result.items(): self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failed".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 99f42d9b8..1ded211ed 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -18,6 +18,14 @@ def is_report(obj): return False +def get_report(module_name, report_name): + """ + Return a specific report from within a module. + """ + module = importlib.import_module('reports.{}'.format(module_name)) + return getattr(module, report_name) + + def get_reports(): """ Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: From 3395b510868a66c7e31e0a7bc589040b225b323a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 16:36:43 -0400 Subject: [PATCH 07/24] Cleaned up the API quite a bit --- netbox/extras/api/views.py | 93 ++++++++++++++++++-------------------- netbox/extras/reports.py | 40 ++++++++-------- netbox/extras/views.py | 8 ++-- 3 files changed, 70 insertions(+), 71 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 27a806155..65e1e5182 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -95,74 +95,69 @@ class ReportViewSet(ViewSet): exclude_from_schema = True lookup_value_regex = '[^/]+' # Allow dots + def _retrieve_report(self, pk): + + # Read the PK as "." + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + + # Raise a 404 on an invalid Report module/name + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + return report + def list(self, request): - - # Compile all reports + """ + Compile all reports and their related results (if any). Result data is deferred in the list view. + """ report_list = [] - for module_name, reports in get_reports(): - for report_name, report_cls in reports: - data = { - 'module': module_name, - 'name': report_name, - 'description': report_cls.description, - 'test_methods': report_cls().test_methods, - 'result': None, - } - try: - result = ReportResult.objects.defer('data').get(report='{}.{}'.format(module_name, report_name)) - data['result'] = result - except ReportResult.DoesNotExist: - pass - report_list.append(data) - serializer = serializers.ReportSerializer(report_list, many=True, context={'request': request}) + # 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() + report_list.append(report) + + serializer = serializers.ReportSerializer(report_list, many=True) return Response(serializer.data) def retrieve(self, request, pk): + """ + Retrieve a single Report identified as ".". + """ - # Retrieve report by . - if '.' not in pk: - raise Http404 - module_name, report_name = pk.split('.', 1) - report_cls = get_report(module_name, report_name) - data = { - 'module': module_name, - 'name': report_name, - 'description': report_cls.description, - 'test_methods': report_cls().test_methods, - 'result': None, - } + # Retrieve the Report and ReportResult, if any. + report = self._retrieve_report(pk) + report.result = ReportResult.objects.filter(report=report.full_name).first() - # Attach report result - try: - result = ReportResult.objects.get(report='{}.{}'.format(module_name, report_name)) - data['result'] = result - except ReportResult.DoesNotExist: - pass - - serializer = serializers.ReportDetailSerializer(data) + serializer = serializers.ReportDetailSerializer(report) return Response(serializer.data) @detail_route() def run(self, request, pk): + """ + Run a Report and create a new ReportResult, overwriting any previous result for the Report. + """ - # Retrieve report by . - if '.' not in pk: - raise Http404 - module_name, report_name = pk.split('.', 1) - report_cls = get_report(module_name, report_name) - - # Run the report - report = report_cls() + # Retrieve and run the Report. + report = self._retrieve_report(pk) result = report.run() - # Save the ReportResult + # Delete the old ReportResult (if any) and save the new one. ReportResult.objects.filter(report=pk).delete() - ReportResult(report=pk, failed=report.failed, data=result).save() + report.result = ReportResult(report=pk, failed=report.failed, data=result) + report.result.save() - return Response('Report completed.') + serializer = serializers.ReportDetailSerializer(report) + + return Response(serializer.data) class RecentActivityViewSet(ReadOnlyModelViewSet): diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 1ded211ed..e5fa24553 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -6,7 +6,7 @@ import pkgutil from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING -import reports as user_reports +import reports as custom_reports def is_report(obj): @@ -23,7 +23,8 @@ def get_report(module_name, report_name): Return a specific report from within a module. """ module = importlib.import_module('reports.{}'.format(module_name)) - return getattr(module, report_name) + report = getattr(module, report_name, None) + return report() def get_reports(): @@ -31,27 +32,18 @@ def get_reports(): Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: [ - (module_name, ( - (report_name, report_class), - (report_name, report_class) - ), - (module_name, ( - (report_name, report_class), - (report_name, report_class) - ) + (module_name, (report_class, report_class, report_class, ...)), + (module_name, (report_class, report_class, report_class, ...)), + ... ] """ module_list = [] - # Iterate through all modules within the reports path - for importer, module_name, is_pkg in pkgutil.walk_packages(user_reports.__path__): + # Iterate through all modules within the reports path. These are the user-defined files in which reports are + # defined. + for importer, module_name, is_pkg in pkgutil.walk_packages(custom_reports.__path__): module = importlib.import_module('reports.{}'.format(module_name)) - report_list = [] - - # Iterate through all Report classes within the module - for report_name, report_class in inspect.getmembers(module, is_report): - report_list.append((report_name, report_class)) - + report_list = [cls() for _, cls in inspect.getmembers(module, is_report)] module_list.append((module_name, report_list)) return module_list @@ -105,6 +97,18 @@ class Report(object): raise Exception("A report must contain at least one test method.") self.test_methods = test_methods + @property + def module(self): + return self.__module__.rsplit('.', 1)[1] + + @property + def name(self): + return self.__class__.__name__ + + @property + def full_name(self): + return '.'.join([self.module, self.name]) + def _log(self, obj, message, level=LOG_DEFAULT): """ Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d39d8c17c..d9549bd19 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -58,11 +58,11 @@ class ReportListView(View): foo = [] for module, report_list in reports: module_reports = [] - for report_name, report_class in report_list: + for report in report_list: module_reports.append({ - 'name': report_name, - 'description': report_class.description, - 'results': results.get('{}.{}'.format(module, report_name), None) + 'name': report.name, + 'description': report.description, + 'results': results.get(report.full_name, None) }) foo.append((module, module_reports)) From 9a1781e6e799018b1f2f288e9d82124d4b0c3708 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 16:55:25 -0400 Subject: [PATCH 08/24] Added url field for nested report results --- netbox/extras/api/serializers.py | 7 ++++++- netbox/extras/api/views.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 34e4312ac..8998b509b 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -139,10 +139,15 @@ class ReportResultSerializer(serializers.ModelSerializer): class NestedReportResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='report', + lookup_url_kwarg='pk' + ) class Meta: model = ReportResult - fields = ['created', 'user', 'failed'] + fields = ['url', 'created', 'user', 'failed'] class ReportSerializer(serializers.Serializer): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 65e1e5182..6aaa8ba4b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -123,7 +123,9 @@ class ReportViewSet(ViewSet): report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first() report_list.append(report) - serializer = serializers.ReportSerializer(report_list, many=True) + serializer = serializers.ReportSerializer(report_list, many=True, context={ + 'request': request, + }) return Response(serializer.data) From 696d91daa3d46fd395594bd36c6f00069850e5d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 17:17:28 -0400 Subject: [PATCH 09/24] Prettied up the reports list --- netbox/project-static/css/base.css | 12 ++++++++++ netbox/templates/extras/report_list.html | 30 ++++++++++++++---------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 0f6b24077..ce6a8e2d8 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -338,6 +338,18 @@ table.component-list tr.ipaddress:hover td { background-color: #e6f7f7; } +/* Reports */ +table.reports td.method { + font-family: monospace; + padding-left: 30px; +} +table.reports td.stats label { + display: inline-block; + line-height: 14px; + margin-bottom: 0; + min-width: 40px; +} + /* AJAX loader */ .loading { position: fixed; diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 492da8b75..947e22fb5 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -7,39 +7,43 @@
{% for module, module_reports in reports %}

{{ module|bettertitle }}

- +
+ - {% for report in module_reports %} - + + {% if report.results %} {% else %} {% endif %} - {% for method, stats in report.results.data.items %} - + - {% endfor %} {% endfor %} From f4c87b37396eae003921c2c6dc1a3db5fa7ed1e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 17:31:16 -0400 Subject: [PATCH 10/24] Removed custom permission --- netbox/extras/api/views.py | 5 +++++ netbox/extras/migrations/0008_reports.py | 3 +-- netbox/extras/models.py | 3 --- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 6aaa8ba4b..216ea42a0 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from rest_framework.decorators import detail_route +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet @@ -148,6 +149,10 @@ class ReportViewSet(ViewSet): Run a Report and create a new ReportResult, overwriting any previous result for the Report. """ + # Check that the user has permission to run reports. + if not request.user.has_perm('extras.add_reportresult'): + raise PermissionDenied("This user does not have permission to run reports.") + # Retrieve and run the Report. report = self._retrieve_report(pk) result = report.run() diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index 0cfe48ba5..c9fc16cc3 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-09-22 15:21 +# Generated by Django 1.11.4 on 2017-09-26 21:25 from __future__ import unicode_literals from django.conf import settings @@ -28,7 +28,6 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['report'], - 'permissions': (('run_report', 'Run a report and save the results'),), }, ), ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d1eee12cb..5181e88e9 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -405,9 +405,6 @@ class ReportResult(models.Model): class Meta: ordering = ['report'] - permissions = ( - ('run_report', 'Run a report and save the results'), - ) # From 2fbb39bf6fa6dfd4eaa9f7bcae8f1e8772014cbc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Sep 2017 17:39:22 -0400 Subject: [PATCH 11/24] Started adding a view for individual reports --- netbox/extras/reports.py | 2 + netbox/extras/urls.py | 1 + netbox/extras/views.py | 40 +++++++++++++------ netbox/templates/extras/inc/report_label.html | 4 +- netbox/templates/extras/report.html | 31 ++++++++++++++ netbox/templates/extras/report_list.html | 9 ++--- 6 files changed, 68 insertions(+), 19 deletions(-) create mode 100644 netbox/templates/extras/report.html diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index e5fa24553..7af639e49 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -24,6 +24,8 @@ def get_report(module_name, report_name): """ module = importlib.import_module('reports.{}'.format(module_name)) report = getattr(module, report_name, None) + if report is None: + return None return report() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 3828d696e..360e7d8e5 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -14,5 +14,6 @@ urlpatterns = [ # Reports url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), + url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d9549bd19..42bc10e6d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,16 +1,14 @@ from __future__ import unicode_literals -from collections import OrderedDict from django.contrib.auth.mixins import PermissionRequiredMixin +from django.http import Http404 from django.shortcuts import get_object_or_404, render -from django.urls import reverse from django.views.generic import View -from . import reports from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm from .models import ImageAttachment, ReportResult -from .reports import get_reports +from .reports import get_report, get_reports # @@ -55,17 +53,35 @@ class ReportListView(View): reports = get_reports() results = {r.report: r for r in ReportResult.objects.all()} - foo = [] + ret = [] for module, report_list in reports: module_reports = [] for report in report_list: - module_reports.append({ - 'name': report.name, - 'description': report.description, - 'results': results.get(report.full_name, None) - }) - foo.append((module, module_reports)) + report.result = results.get(report.full_name, None) + module_reports.append(report) + ret.append((module, module_reports)) return render(request, 'extras/report_list.html', { - 'reports': foo, + 'reports': ret, + }) + + +class ReportView(View): + """ + Display a single Report and its associated ReportResult (if any). + """ + + def get(self, request, name): + + # Retrieve the Report by "." + 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.result = ReportResult.objects.filter(report=report.full_name).first() + + return render(request, 'extras/report.html', { + 'report': report, }) diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html index 8e2a2a5b5..67fc0556b 100644 --- a/netbox/templates/extras/inc/report_label.html +++ b/netbox/templates/extras/inc/report_label.html @@ -1,6 +1,6 @@ -{% if report.results.failed %} +{% if report.result.failed %} -{% elif report.results %} +{% elif report.result %} {% else %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html new file mode 100644 index 000000000..19cf24257 --- /dev/null +++ b/netbox/templates/extras/report.html @@ -0,0 +1,31 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ report.name }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+

{{ report.name }}{% include 'extras/inc/report_label.html' %}

+
+
+ {% if report.description %} +

{{ report.description }}

+ {% endif %} + {% if report.result %} +

Last run: {{ report.result.created }}

+ {% else %} +

Last run: Never

+ {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 947e22fb5..251394f33 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -20,20 +20,19 @@ {% for report in module_reports %}
- {% if report.results %} - + {% if report.result %} + {% else %} {% endif %} - {% for method, stats in report.results.data.items %} + {% for method, stats in report.result.data.items %} {% endfor %} From 1ad099d9fd202f886a2f48033683d84bcae74aa9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 14:54:11 -0400 Subject: [PATCH 17/24] Added nav menu link to reports list --- netbox/templates/_base.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 3a2cd85d4..c81b12d72 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -28,7 +28,7 @@ - + @@ -27,9 +27,9 @@ {% if report.result %} - + {% else %} - + {% endif %} {% for method, stats in report.result.data.items %} @@ -51,19 +51,23 @@ {% endfor %}
- +
{% endblock %} From 21485ca6e2d3a56f816259608438ecb83166e0a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:04:37 -0400 Subject: [PATCH 20/24] Restrict the running of reports via API to POST requests --- docs/miscellaneous/reports.md | 0 netbox/extras/api/views.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 docs/miscellaneous/reports.md diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 81eb5e739..bd1d33fa3 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -143,7 +143,7 @@ class ReportViewSet(ViewSet): return Response(serializer.data) - @detail_route() + @detail_route(methods=['post']) def run(self, request, pk): """ Run a Report and create a new ReportResult, overwriting any previous result for the Report. From e630a1ace1a4ca24fa14e7cc1bd73ea29467e9c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:25:42 -0400 Subject: [PATCH 21/24] Added docs for reports --- docs/miscellaneous/reports.md | 119 ++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 2 files changed, 121 insertions(+) diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md index e69de29bb..79e4fb085 100644 --- a/docs/miscellaneous/reports.md +++ b/docs/miscellaneous/reports.md @@ -0,0 +1,119 @@ +# NetBox Reports + +A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that: + +* All top-of-rack switches have a console connection +* Every router has a loopback interface with an IP address assigned +* Each interface description conforms to a standard format +* Every site has a minimum set of VLANs defined +* All IP addresses have a parent prefix + +...and so on. Reports are completely customizable, so there's practically no limit to what you can test for. + +## Writing Reports + +Reports must be saved as files in the `netbox/reports/` path within the NetBox installation path. Each file created within this path is considered a separate module. Each module holds one or more reports, each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. + +!!! warning + The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. + +For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`. + +``` +from extras.reports import Report + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + +class DeviceIPsReport(Report): + description = "Check that every device has a primary IP address assigned" +``` + +Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections. + +``` +from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE +from dcim.models import ConsolePort, Device, PowerPort +from extras.reports import Report + + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + + def test_console_connection(self): + + # Check that every console port for every active device has a connection defined. + for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE): + if console_port.cs_port is None: + self.log_failure( + console_port.device, + "No console connection defined for {}".format(console_port.name) + ) + elif console_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + console_port.device, + "Console connection for {} marked as planned".format(console_port.name) + ) + else: + self.log_success(console_port.device) + + def test_power_connections(self): + + # Check that every active device has at least two connected power supplies. + for device in Device.objects.filter(status=STATUS_ACTIVE): + connected_ports = 0 + for power_port in PowerPort.objects.filter(device=device): + if power_port.power_outlet is not None: + connected_ports += 1 + if power_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + device, + "Power connection for {} marked as planned".format(power_port.name) + ) + if connected_ports < 2: + self.log_failure( + device, + "{} connected power supplies found (2 needed)".format(connected_ports) + ) + else: + self.log_success(device) +``` + +As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. + +!!! warning + Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. + +The following methods are available to log results within a report: + +* log(message) +* log_success(object, message=None) +* log_info(object, message) +* log_warning(object, message) +* log_failure(object, message) + +The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. + +Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report. + +## Running Reports + +### Via the Web UI + +Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.) + +Once a report has been run, its associated results will be included in the report view. + +### Via the API + +To run a report via the API, simply issue a POST request. Reports are identified by their module and class name. + +``` + POST /api/extras/reports/./ +``` + +Our example report above would be called as: + +``` + POST /api/extras/reports/devices.DeviceConnectionsReport/ +``` diff --git a/mkdocs.yml b/mkdocs.yml index c26a9ed17..e3ac9f50b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,8 @@ pages: - 'Examples': 'api/examples.md' - 'Shell': - 'Introduction': 'shell/intro.md' + - 'Miscellaneous': + - 'Reports': 'miscellaneous/reports.md' - 'Development': - 'Utility Views': 'development/utility-views.md' From 67f0dfa4498b852a1f9f42f1d0352800696dd093 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:32:59 -0400 Subject: [PATCH 22/24] We need PostgreSQL 9.4 or higher for jsonb fields --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 1576da4cf..d7acf1db2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,8 @@ python: install: - pip install -r requirements.txt - pip install pep8 +addons: + - postgresql: "9.4" script: - ./scripts/cibuild.sh after_success: From 669aee2d73ed9e84df98f78de84fc565240e6f3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:43:35 -0400 Subject: [PATCH 23/24] Bumped psycopg2 hoping to fix jsonb errors in CI --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8cd72419f..82041879e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ ncclient==0.5.3 netaddr==0.7.18 paramiko>=2.0.0 Pillow>=4.0.0 -psycopg2>=2.6.1 +psycopg2>=2.7.3 py-gfm>=0.1.3 pycrypto>=2.6.1 sqlparse>=0.2 From c65af6a74fd17438d0a2f73397957389165de55d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 17:03:47 -0400 Subject: [PATCH 24/24] Trying to get Travis to run PostgreSQL 9.4 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index d7acf1db2..2c22e30b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ sudo: required services: - docker + - postgresql env: - DOCKER_TAG=$TRAVIS_TAG
NameStatus Description Last RunStatus
{{ report.name }} + + {{ report.name }} + + {% include 'extras/inc/report_label.html' %} + {{ report.description|default:"" }}{{ report.results.created }}Never{% include 'extras/inc/report_label.html' %}
-
- - - - -
- {{ method }} +
+ {{ method }} + + + + +
- - {{ report.name }} + {{ report.name }} {% include 'extras/inc/report_label.html' %} {{ report.description|default:"" }}{{ report.results.created }}{{ report.result.created }}Never
{{ method }} From 571b817f0483d567e024dd152d84e024929fcfda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 12:50:32 -0400 Subject: [PATCH 12/24] Moved ReportResult creation into Report.run() --- netbox/extras/api/views.py | 9 ++------- netbox/extras/reports.py | 27 ++++++++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 216ea42a0..81eb5e739 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -153,14 +153,9 @@ class ReportViewSet(ViewSet): if not request.user.has_perm('extras.add_reportresult'): raise PermissionDenied("This user does not have permission to run reports.") - # Retrieve and run the Report. + # Retrieve and run the Report. This will create a new ReportResult. report = self._retrieve_report(pk) - result = report.run() - - # Delete the old ReportResult (if any) and save the new one. - ReportResult.objects.filter(report=pk).delete() - report.result = ReportResult(report=pk, failed=report.failed, data=result) - report.result.save() + report.run() serializer = serializers.ReportDetailSerializer(report) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 7af639e49..7bf8951ea 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -6,6 +6,7 @@ import pkgutil from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING +from .models import ReportResult import reports as custom_reports @@ -34,8 +35,8 @@ def get_reports(): Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: [ - (module_name, (report_class, report_class, report_class, ...)), - (module_name, (report_class, report_class, report_class, ...)), + (module_name, (report, report, report, ...)), + (module_name, (report, report, report, ...)), ... ] """ @@ -56,7 +57,7 @@ class Report(object): NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each report must have one or more test methods named `test_*`. - The `results` attribute of a completed report will take the following form: + The `_results` attribute of a completed report will take the following form: { 'test_bar': { @@ -79,7 +80,7 @@ class Report(object): def __init__(self): - self.results = OrderedDict() + self._results = OrderedDict() self.active_test = None self.failed = False @@ -88,7 +89,7 @@ class Report(object): for method in dir(self): if method.startswith('test_') and callable(getattr(self, method)): test_methods.append(method) - self.results[method] = OrderedDict([ + self._results[method] = OrderedDict([ ('success', 0), ('info', 0), ('warning', 0), @@ -118,7 +119,7 @@ class Report(object): if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) logline = [timezone.now().isoformat(), level, str(obj), message] - self.results[self.active_test]['log'].append(logline) + self._results[self.active_test]['log'].append(logline) def log_success(self, obj, message=None): """ @@ -126,28 +127,28 @@ class Report(object): """ if message: self._log(obj, message, level=LOG_SUCCESS) - self.results[self.active_test]['success'] += 1 + self._results[self.active_test]['success'] += 1 def log_info(self, obj, message): """ Log an informational message. """ self._log(obj, message, level=LOG_INFO) - self.results[self.active_test]['info'] += 1 + self._results[self.active_test]['info'] += 1 def log_warning(self, obj, message): """ Log a warning. """ self._log(obj, message, level=LOG_WARNING) - self.results[self.active_test]['warning'] += 1 + self._results[self.active_test]['warning'] += 1 def log_failure(self, obj, message): """ Log a failure. Calling this method will automatically mark the report as failed. """ self._log(obj, message, level=LOG_FAILURE) - self.results[self.active_test]['failed'] += 1 + self._results[self.active_test]['failed'] += 1 self.failed = True def run(self): @@ -159,4 +160,8 @@ class Report(object): test_method = getattr(self, method_name) test_method() - return self.results + # 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 From b65e9fe0f53ef1690c10c9be76fe2a7b727dd24b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 12:50:52 -0400 Subject: [PATCH 13/24] Fixed runreport management command --- .../extras/management/commands/runreport.py | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 70893b934..a6ae18758 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -16,36 +16,30 @@ class Command(BaseCommand): def handle(self, *args, **options): - # Gather all reports to be run + # Gather all available reports reports = get_reports() # Run reports - for module_name, report in reports: - for report_name, report_cls in report: - report_name_full = '{}.{}'.format(module_name, report_name) - if module_name in options['reports'] or report_name_full in options['reports']: + for module_name, report_list in reports: + for report in report_list: + if module_name in options['reports'] or report.full_namel in options['reports']: - # Run the report + # Run the report and create a new ReportResult self.stdout.write( - "[{:%H:%M:%S}] Running {}.{}...".format(timezone.now(), module_name, report_name) + "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) ) - report = report_cls() - result = report.run() - - # Record the results - ReportResult.objects.filter(report=report_name_full).delete() - ReportResult(report=report_name_full, failed=report.failed, data=result).save() + report.run() # Report on success/failure status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - for test_name, attrs in result.items(): + for test_name, attrs in report.result.data.items(): self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failed".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] ) ) self.stdout.write( - "[{:%H:%M:%S}] {}.{}: {}".format(timezone.now(), module_name, report_name, status) + "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) ) # Wrap things up From 2b33e78fd39f72ed3de4ca368c15a4e34215717c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 12:51:10 -0400 Subject: [PATCH 14/24] Added a run view for reports --- netbox/extras/urls.py | 1 + netbox/extras/views.py | 35 +++++++++++++++++++++++++++-- netbox/templates/extras/report.html | 13 ++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 360e7d8e5..1ac7fce6c 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -15,5 +15,6 @@ urlpatterns = [ # Reports url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), + url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 42bc10e6d..e92b28187 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,13 +1,16 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib import messages from django.http import Http404 -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.safestring import mark_safe from django.views.generic import View +from utilities.forms import ConfirmationForm from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm -from .models import ImageAttachment, ReportResult +from .models import ImageAttachment, ReportResult, UserAction from .reports import get_report, get_reports @@ -84,4 +87,32 @@ class ReportView(View): return render(request, 'extras/report.html', { 'report': report, + 'run_form': ConfirmationForm(), }) + + +class ReportRunView(PermissionRequiredMixin, View): + """ + Run a Report and record a new ReportResult. + """ + permission_required = 'extras.add_reportresult' + + def post(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + 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)) + UserAction.objects.log_create(request.user, report.result, msg) + + return redirect('extras:report', name=report.full_name) diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 19cf24257..cb3e716f5 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -5,16 +5,23 @@ {% block content %}
-
+ -
-
+ {% if perms.extras.add_reportresult %} +
+
+ {% csrf_token %} + {{ run_form }} + +
+
+ {% endif %}

{{ report.name }}{% include 'extras/inc/report_label.html' %}

From 6c6b67330fe778989f9663cf6013ab23368200d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 13:35:18 -0400 Subject: [PATCH 15/24] Expanded report view --- netbox/extras/reports.py | 19 +++++++--- netbox/templates/extras/report.html | 56 ++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 7bf8951ea..421c8456f 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -93,7 +93,7 @@ class Report(object): ('success', 0), ('info', 0), ('warning', 0), - ('failed', 0), + ('failure', 0), ('log', []), ]) if not test_methods: @@ -118,8 +118,19 @@ class Report(object): """ if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) - logline = [timezone.now().isoformat(), level, str(obj), message] - self._results[self.active_test]['log'].append(logline) + self._results[self.active_test]['log'].append( + timezone.now().isoformat(), + LOG_LEVEL_CODES.get(level), + str(obj) if obj else None, + obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None, + message, + ) + + def log(self, message): + """ + Log a message which is not associated with a particular object. + """ + self._log(None, message, level=LOG_DEFAULT) def log_success(self, obj, message=None): """ @@ -148,7 +159,7 @@ class Report(object): Log a failure. Calling this method will automatically mark the report as failed. """ self._log(obj, message, level=LOG_FAILURE) - self._results[self.active_test]['failed'] += 1 + self._results[self.active_test]['failure'] += 1 self.failed = True def run(self): diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index cb3e716f5..2c087e437 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -24,7 +24,7 @@ {% endif %}

{{ report.name }}{% include 'extras/inc/report_label.html' %}

-
+
{% if report.description %}

{{ report.description }}

{% endif %} @@ -34,5 +34,59 @@

Last run: Never

{% endif %}
+
+ {% if report.result %} + + + + + + + + + + {% for method, data in report.result.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} +
TimeLevelObjectMessage
{{ method }}
{{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% endif %} + {{ message }}
+ {% else %} +
No results are available for this report. Please run the report first.
+ {% endif %} +
+
+ {% if report.result %} +
+
+ Methods +
+
    + {% for method, data in report.result.data.items %} +
  • + {{ method }} + {{ data.log|length }} +
  • + {% endfor %} +
+
+ {% endif %} +
{% endblock %} From f9a677c1a3600e7e08fac820d0b1dfedcb04cb7a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 13:36:50 -0400 Subject: [PATCH 16/24] Bugfixes --- netbox/extras/reports.py | 4 ++-- netbox/templates/extras/report_list.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 421c8456f..921aab380 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -118,13 +118,13 @@ class Report(object): """ if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) - self._results[self.active_test]['log'].append( + self._results[self.active_test]['log'].append(( timezone.now().isoformat(), LOG_LEVEL_CODES.get(level), str(obj) if obj else None, obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None, message, - ) + )) def log(self, message): """ diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 251394f33..fde83ddc3 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -41,7 +41,7 @@ - +
Name Status DescriptionLast RunLast Run
{{ report.description|default:"" }}{{ report.result.created }}{{ report.result.created }}NeverNever