mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge pull request #1544 from digitalocean/reports
Closes #1511: Implemented reports
This commit is contained in:
commit
afbe0bc307
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
/netbox/netbox/configuration.py
|
/netbox/netbox/configuration.py
|
||||||
/netbox/netbox/ldap_config.py
|
/netbox/netbox/ldap_config.py
|
||||||
|
/netbox/reports/*
|
||||||
|
!/netbox/reports/__init__.py
|
||||||
/netbox/static
|
/netbox/static
|
||||||
.idea
|
.idea
|
||||||
/*.sh
|
/*.sh
|
||||||
|
@ -2,6 +2,7 @@ sudo: required
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
- docker
|
- docker
|
||||||
|
- postgresql
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- DOCKER_TAG=$TRAVIS_TAG
|
- DOCKER_TAG=$TRAVIS_TAG
|
||||||
@ -13,6 +14,8 @@ python:
|
|||||||
install:
|
install:
|
||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
- pip install pep8
|
- pip install pep8
|
||||||
|
addons:
|
||||||
|
- postgresql: "9.4"
|
||||||
script:
|
script:
|
||||||
- ./scripts/cibuild.sh
|
- ./scripts/cibuild.sh
|
||||||
after_success:
|
after_success:
|
||||||
|
119
docs/miscellaneous/reports.md
Normal file
119
docs/miscellaneous/reports.md
Normal file
@ -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/<module>.<name>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Our example report above would be called as:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/extras/reports/devices.DeviceConnectionsReport/
|
||||||
|
```
|
@ -27,6 +27,8 @@ pages:
|
|||||||
- 'Examples': 'api/examples.md'
|
- 'Examples': 'api/examples.md'
|
||||||
- 'Shell':
|
- 'Shell':
|
||||||
- 'Introduction': 'shell/intro.md'
|
- 'Introduction': 'shell/intro.md'
|
||||||
|
- 'Miscellaneous':
|
||||||
|
- 'Reports': 'miscellaneous/reports.md'
|
||||||
- 'Development':
|
- 'Development':
|
||||||
- 'Utility Views': 'development/utility-views.md'
|
- 'Utility Views': 'development/utility-views.md'
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from rest_framework import serializers
|
|||||||
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
|
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
|
||||||
from dcim.models import Device, Rack, Site
|
from dcim.models import Device, Rack, Site
|
||||||
from extras.models import (
|
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 users.api.serializers import NestedUserSerializer
|
||||||
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
|
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
|
||||||
@ -127,6 +127,41 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Reports
|
||||||
|
#
|
||||||
|
|
||||||
|
class ReportResultSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReportResult
|
||||||
|
fields = ['created', 'user', 'failed', 'data']
|
||||||
|
|
||||||
|
|
||||||
|
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 = ['url', '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
|
# User actions
|
||||||
#
|
#
|
||||||
|
@ -28,6 +28,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet)
|
|||||||
# Image attachments
|
# Image attachments
|
||||||
router.register(r'image-attachments', views.ImageAttachmentViewSet)
|
router.register(r'image-attachments', views.ImageAttachmentViewSet)
|
||||||
|
|
||||||
|
# Reports
|
||||||
|
router.register(r'reports', views.ReportViewSet, base_name='report')
|
||||||
|
|
||||||
# Recent activity
|
# Recent activity
|
||||||
router.register(r'recent-activity', views.RecentActivityViewSet)
|
router.register(r'recent-activity', views.RecentActivityViewSet)
|
||||||
|
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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 django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from extras import filters
|
from extras import filters
|
||||||
from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
|
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||||
|
from extras.reports import get_report, get_reports
|
||||||
from utilities.api import WritableSerializerMixin
|
from utilities.api import WritableSerializerMixin
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
@ -88,6 +91,77 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ReportViewSet(ViewSet):
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
exclude_from_schema = True
|
||||||
|
lookup_value_regex = '[^/]+' # Allow dots
|
||||||
|
|
||||||
|
def _retrieve_report(self, pk):
|
||||||
|
|
||||||
|
# Read the PK as "<module>.<report>"
|
||||||
|
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 and their related results (if any). Result data is deferred in the list view.
|
||||||
|
"""
|
||||||
|
report_list = []
|
||||||
|
|
||||||
|
# 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, context={
|
||||||
|
'request': request,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def retrieve(self, request, pk):
|
||||||
|
"""
|
||||||
|
Retrieve a single Report identified as "<module>.<report>".
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Retrieve the Report and ReportResult, if any.
|
||||||
|
report = self._retrieve_report(pk)
|
||||||
|
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||||
|
|
||||||
|
serializer = serializers.ReportDetailSerializer(report)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@detail_route(methods=['post'])
|
||||||
|
def run(self, request, pk):
|
||||||
|
"""
|
||||||
|
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. This will create a new ReportResult.
|
||||||
|
report = self._retrieve_report(pk)
|
||||||
|
report.run()
|
||||||
|
|
||||||
|
serializer = serializers.ReportDetailSerializer(report)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
class RecentActivityViewSet(ReadOnlyModelViewSet):
|
class RecentActivityViewSet(ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
List all UserActions to provide a log of recent activity.
|
List all UserActions to provide a log of recent activity.
|
||||||
|
@ -62,3 +62,17 @@ ACTION_CHOICES = (
|
|||||||
(ACTION_DELETE, 'deleted'),
|
(ACTION_DELETE, 'deleted'),
|
||||||
(ACTION_BULK_DELETE, 'bulk 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',
|
||||||
|
}
|
||||||
|
48
netbox/extras/management/commands/runreport.py
Normal file
48
netbox/extras/management/commands/runreport.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from extras.models import ReportResult
|
||||||
|
from extras.reports import get_reports
|
||||||
|
|
||||||
|
|
||||||
|
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 available reports
|
||||||
|
reports = get_reports()
|
||||||
|
|
||||||
|
# Run 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 and create a new ReportResult
|
||||||
|
self.stdout.write(
|
||||||
|
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||||
|
)
|
||||||
|
report.run()
|
||||||
|
|
||||||
|
# Report on success/failure
|
||||||
|
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
|
||||||
|
for test_name, attrs in report.result.data.items():
|
||||||
|
self.stdout.write(
|
||||||
|
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||||
|
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap things up
|
||||||
|
self.stdout.write(
|
||||||
|
"[{:%H:%M:%S}] Finished".format(timezone.now())
|
||||||
|
)
|
33
netbox/extras/migrations/0008_reports.py
Normal file
33
netbox/extras/migrations/0008_reports.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-09-26 21:25
|
||||||
|
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')),
|
||||||
|
('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)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['report'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -6,6 +6,7 @@ import graphviz
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@ -388,6 +389,24 @@ class ImageAttachment(models.Model):
|
|||||||
return None
|
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_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:
|
||||||
|
ordering = ['report']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# User actions
|
# User actions
|
||||||
#
|
#
|
||||||
|
178
netbox/extras/reports.py
Normal file
178
netbox/extras/reports.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
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
|
||||||
|
from .models import ReportResult
|
||||||
|
import reports as custom_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_report(module_name, report_name):
|
||||||
|
"""
|
||||||
|
Return a specific report from within a module.
|
||||||
|
"""
|
||||||
|
module = importlib.import_module('reports.{}'.format(module_name))
|
||||||
|
report = getattr(module, report_name, None)
|
||||||
|
if report is None:
|
||||||
|
return None
|
||||||
|
return report()
|
||||||
|
|
||||||
|
|
||||||
|
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, report, report, ...)),
|
||||||
|
(module_name, (report, report, report, ...)),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
module_list = []
|
||||||
|
|
||||||
|
# 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 = [cls() for _, cls in inspect.getmembers(module, is_report)]
|
||||||
|
module_list.append((module_name, report_list))
|
||||||
|
|
||||||
|
return module_list
|
||||||
|
|
||||||
|
|
||||||
|
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': [
|
||||||
|
(<datetime>, <level>, <object>, <message>),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'test_foo': {
|
||||||
|
'failures': 0,
|
||||||
|
'log': [
|
||||||
|
(<datetime>, <level>, <object>, <message>),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
description = None
|
||||||
|
|
||||||
|
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):
|
||||||
|
if method.startswith('test_') and callable(getattr(self, method)):
|
||||||
|
test_methods.append(method)
|
||||||
|
self._results[method] = OrderedDict([
|
||||||
|
('success', 0),
|
||||||
|
('info', 0),
|
||||||
|
('warning', 0),
|
||||||
|
('failure', 0),
|
||||||
|
('log', []),
|
||||||
|
])
|
||||||
|
if not test_methods:
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if level not in LOG_LEVEL_CODES:
|
||||||
|
raise Exception("Unknown logging level: {}".format(level))
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
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]['failure'] += 1
|
||||||
|
self.failed = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 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
|
@ -12,4 +12,9 @@ urlpatterns = [
|
|||||||
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
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'),
|
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'),
|
||||||
|
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
|
||||||
|
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.shortcuts import get_object_or_404
|
from django.contrib import messages
|
||||||
|
from django.http import Http404
|
||||||
|
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 utilities.views import ObjectDeleteView, ObjectEditView
|
||||||
from .forms import ImageAttachmentForm
|
from .forms import ImageAttachmentForm
|
||||||
from .models import ImageAttachment
|
from .models import ImageAttachment, ReportResult, UserAction
|
||||||
|
from .reports import get_report, get_reports
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Image attachments
|
||||||
|
#
|
||||||
|
|
||||||
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'extras.change_imageattachment'
|
permission_required = 'extras.change_imageattachment'
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
@ -30,3 +40,79 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
|||||||
|
|
||||||
def get_return_url(self, request, imageattachment):
|
def get_return_url(self, request, imageattachment):
|
||||||
return imageattachment.parent.get_absolute_url()
|
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.report: r for r in ReportResult.objects.all()}
|
||||||
|
|
||||||
|
ret = []
|
||||||
|
for module, report_list in reports:
|
||||||
|
module_reports = []
|
||||||
|
for report in report_list:
|
||||||
|
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': ret,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ReportView(View):
|
||||||
|
"""
|
||||||
|
Display a single Report and its associated ReportResult (if any).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request, name):
|
||||||
|
|
||||||
|
# Retrieve the Report by "<module>.<report>"
|
||||||
|
module_name, report_name = name.split('.')
|
||||||
|
report = get_report(module_name, report_name)
|
||||||
|
if report is None:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
# Attach the ReportResult (if any)
|
||||||
|
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||||
|
|
||||||
|
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>.<report>"
|
||||||
|
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)
|
||||||
|
@ -339,6 +339,18 @@ table.component-list td.subtable td {
|
|||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 */
|
/* AJAX loader */
|
||||||
.loading {
|
.loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
0
netbox/reports/__init__.py
Normal file
0
netbox/reports/__init__.py
Normal file
@ -28,7 +28,7 @@
|
|||||||
<div id="navbar" class="navbar-collapse collapse">
|
<div id="navbar" class="navbar-collapse collapse">
|
||||||
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
||||||
<ul class="nav navbar-nav">
|
<ul class="nav navbar-nav">
|
||||||
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/' %} active{% endif %}">
|
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/reports/' %} active{% endif %}">
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href="{% url 'dcim:site_list' %}"><strong>Sites</strong></a></li>
|
<li><a href="{% url 'dcim:site_list' %}"><strong>Sites</strong></a></li>
|
||||||
@ -52,6 +52,8 @@
|
|||||||
{% if perms.tenancy.add_tenantgroup %}
|
{% if perms.tenancy.add_tenantgroup %}
|
||||||
<li class="subnav"><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="fa fa-plus"></i> Add a Tenant Group</a></li>
|
<li class="subnav"><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="fa fa-plus"></i> Add a Tenant Group</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="divider"></li>
|
||||||
|
<li><a href="{% url 'extras:report_list' %}"><strong>Reports</strong></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
|
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
|
||||||
|
7
netbox/templates/extras/inc/report_label.html
Normal file
7
netbox/templates/extras/inc/report_label.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{% if report.result.failed %}
|
||||||
|
<label class="label label-danger">Failed</label>
|
||||||
|
{% elif report.result %}
|
||||||
|
<label class="label label-success">Passed</label>
|
||||||
|
{% else %}
|
||||||
|
<label class="label label-default">N/A</label>
|
||||||
|
{% endif %}
|
92
netbox/templates/extras/report.html
Normal file
92
netbox/templates/extras/report.html
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
{% extends '_base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block title %}{{ report.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="{% url 'extras:report_list' %}">Reports</a></li>
|
||||||
|
<li><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
|
||||||
|
<li>{{ report.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if perms.extras.add_reportresult %}
|
||||||
|
<div class="pull-right">
|
||||||
|
<form action="{% url 'extras:report_run' name=report.full_name %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ run_form }}
|
||||||
|
<button type="submit" name="_run" class="btn btn-primary"><i class="fa fa-play"></i> Run Report</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<h1>{{ report.name }}{% include 'extras/inc/report_label.html' %}</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
{% if report.description %}
|
||||||
|
<p class="lead">{{ report.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if report.result %}
|
||||||
|
<p>Last run: {{ report.result.created }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">Last run: Never</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
{% if report.result %}
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Object</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% for method, data in report.result.data.items %}
|
||||||
|
<tr>
|
||||||
|
<th colspan="4"><a name="{{ method }}"></a>{{ method }}</th>
|
||||||
|
</tr>
|
||||||
|
{% for time, level, obj, url, message in data.log %}
|
||||||
|
<tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
|
||||||
|
<td>{{ time }}</td>
|
||||||
|
<td>
|
||||||
|
<label class="label label-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if obj and url %}
|
||||||
|
<a href="{{ url }}">{{ obj }}</a>
|
||||||
|
{% elif obj %}
|
||||||
|
{{ obj }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="well">No results are available for this report. Please run the report first.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
{% if report.result %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<strong>Methods</strong>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for method, data in report.result.data.items %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<a href="#{{ method }}">{{ method }}</a>
|
||||||
|
<span class="badge">{{ data.log|length }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
73
netbox/templates/extras/report_list.html
Normal file
73
netbox/templates/extras/report_list.html
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{% extends '_base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% block title %}Reports{% endblock %}</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9">
|
||||||
|
{% for module, module_reports in reports %}
|
||||||
|
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
|
||||||
|
<table class="table table-hover table-headings reports">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-right">Last Run</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for report in module_reports %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'extras:report' name=report.full_name %}" name="report.{{ report.name }}"><strong>{{ report.name }}</strong></a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% include 'extras/inc/report_label.html' %}
|
||||||
|
</td>
|
||||||
|
<td>{{ report.description|default:"" }}</td>
|
||||||
|
{% if report.result %}
|
||||||
|
<td class="text-right">{{ report.result.created }}</td>
|
||||||
|
{% else %}
|
||||||
|
<td class="text-right text-muted">Never</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% for method, stats in report.result.data.items %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="method">
|
||||||
|
{{ method }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right stats">
|
||||||
|
<label class="label label-success">{{ stats.success }}</label>
|
||||||
|
<label class="label label-info">{{ stats.info }}</label>
|
||||||
|
<label class="label label-warning">{{ stats.warning }}</label>
|
||||||
|
<label class="label label-danger">{{ stats.failure }}</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
{% for module, module_reports in reports %}
|
||||||
|
<div class="panel-heading">
|
||||||
|
<strong>{{ module|bettertitle }}</strong>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for report in module_reports %}
|
||||||
|
<a href="#report.{{ report.name }}" class="list-group-item">
|
||||||
|
<i class="fa fa-list-alt"></i> {{ report.name }}
|
||||||
|
<div class="pull-right">
|
||||||
|
{% include 'extras/inc/report_label.html' %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -15,7 +15,7 @@ ncclient==0.5.3
|
|||||||
netaddr==0.7.18
|
netaddr==0.7.18
|
||||||
paramiko>=2.0.0
|
paramiko>=2.0.0
|
||||||
Pillow>=4.0.0
|
Pillow>=4.0.0
|
||||||
psycopg2>=2.6.1
|
psycopg2>=2.7.3
|
||||||
py-gfm>=0.1.3
|
py-gfm>=0.1.3
|
||||||
pycrypto>=2.6.1
|
pycrypto>=2.6.1
|
||||||
sqlparse>=0.2
|
sqlparse>=0.2
|
||||||
|
Loading…
Reference in New Issue
Block a user