Browse Source

Initial work on reports API

Jeremy Stretch 7 years ago
parent
commit
b5ab498e75

+ 3 - 0
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)
 

+ 26 - 1
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.

+ 32 - 42
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']
-                ))
-
-        self.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now()))
+        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']:
+
+                    # 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())
+        )

+ 33 - 0
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'),),
+            },
+        ),
+    ]

+ 21 - 0
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
@@ -389,6 +390,26 @@ class ImageAttachment(models.Model):
 
 
 #
+# 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
 #
 

+ 34 - 0
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):
 

+ 3 - 0
netbox/extras/urls.py

@@ -12,4 +12,7 @@ urlpatterns = [
     url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
 
+    # Reports
+    url(r'^reports/$', views.ReportListView.as_view(), name='report_list'),
+
 ]

+ 30 - 2
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,
+        })

+ 18 - 0
netbox/templates/extras/report_list.html

@@ -0,0 +1,18 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+    <h1>Reports</h1>
+    <div class="row">
+        <div class="col-md-9">
+            {% for module, report_list in reports %}
+                <h2>{{ module|bettertitle }}</h2>
+                <ul>
+                    {% for name, cls in report_list %}
+                        <li>{{ name }}</li>
+                    {% endfor %}
+                </ul>
+            {% endfor %}
+        </div>
+    </div>
+{% endblock %}