From b5ab498e75cbdc29b2fec92c5bcd4e3786bccc44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Sep 2017 16:32:05 -0400 Subject: [PATCH] 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 %}