From 86cd044a68fe4c0310be82c67b27888d25b39393 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Aug 2019 17:47:44 -0400 Subject: [PATCH 01/71] Fixes #3405: Move device component creation logic into template models --- netbox/dcim/models.py | 114 +++++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 0a1b52979..4c22c9549 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -31,6 +31,12 @@ class ComponentTemplateModel(models.Model): class Meta: abstract = True + def instantiate(self, device): + """ + Instantiate a new component on the specified Device. + """ + raise NotImplementedError() + def log_change(self, user, request_id, action): """ Log an ObjectChange including the parent DeviceType. @@ -1010,6 +1016,12 @@ class ConsolePortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return ConsolePort( + device=device, + name=self.name + ) + class ConsoleServerPortTemplate(ComponentTemplateModel): """ @@ -1033,6 +1045,12 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return ConsoleServerPort( + device=device, + name=self.name + ) + class PowerPortTemplate(ComponentTemplateModel): """ @@ -1068,6 +1086,14 @@ class PowerPortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return PowerPort( + device=device, + name=self.name, + maximum_draw=self.maximum_draw, + allocated_draw=self.allocated_draw + ) + class PowerOutletTemplate(ComponentTemplateModel): """ @@ -1112,6 +1138,18 @@ class PowerOutletTemplate(ComponentTemplateModel): "Parent power port ({}) must belong to the same device type".format(self.power_port) ) + def instantiate(self, device): + if self.power_port: + power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + else: + power_port = None + return PowerOutlet( + device=device, + name=self.name, + power_port=power_port, + feed_leg=self.feed_leg + ) + class InterfaceTemplate(ComponentTemplateModel): """ @@ -1159,6 +1197,14 @@ class InterfaceTemplate(ComponentTemplateModel): """ self.type = value + def instantiate(self, device): + return Interface( + device=device, + name=self.name, + type=self.type, + mgmt_only=self.mgmt_only + ) + class FrontPortTemplate(ComponentTemplateModel): """ @@ -1213,6 +1259,19 @@ class FrontPortTemplate(ComponentTemplateModel): ) ) + def instantiate(self, device): + if self.rear_port: + rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + else: + rear_port = None + return FrontPort( + device=device, + name=self.name, + type=self.type, + rear_port=rear_port, + rear_port_position=self.rear_port_position + ) + class RearPortTemplate(ComponentTemplateModel): """ @@ -1243,6 +1302,14 @@ class RearPortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return RearPort( + device=device, + name=self.name, + type=self.type, + positions=self.positions + ) + class DeviceBayTemplate(ComponentTemplateModel): """ @@ -1266,6 +1333,12 @@ class DeviceBayTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return DeviceBay( + device=device, + name=self.name + ) + # # Devices @@ -1640,45 +1713,28 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [ConsolePort(device=self, name=template.name) for template in - self.device_type.consoleport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleport_templates.all()] ) ConsoleServerPort.objects.bulk_create( - [ConsoleServerPort(device=self, name=template.name) for template in - self.device_type.consoleserverport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()] ) PowerPort.objects.bulk_create( - [PowerPort(device=self, name=template.name) for template in - self.device_type.powerport_templates.all()] + [x.instantiate(self) for x in self.device_type.powerport_templates.all()] ) PowerOutlet.objects.bulk_create( - [PowerOutlet(device=self, name=template.name) for template in - self.device_type.poweroutlet_templates.all()] + [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()] ) Interface.objects.bulk_create( - [Interface(device=self, name=template.name, type=template.type, - mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] + [x.instantiate(self) for x in self.device_type.interface_templates.all()] + ) + RearPort.objects.bulk_create( + [x.instantiate(self) for x in self.device_type.rearport_templates.all()] + ) + FrontPort.objects.bulk_create( + [x.instantiate(self) for x in self.device_type.frontport_templates.all()] ) - RearPort.objects.bulk_create([ - RearPort( - device=self, - name=template.name, - type=template.type, - positions=template.positions - ) for template in self.device_type.rearport_templates.all() - ]) - FrontPort.objects.bulk_create([ - FrontPort( - device=self, - name=template.name, - type=template.type, - rear_port=RearPort.objects.get(device=self, name=template.rear_port.name), - rear_port_position=template.rear_port_position, - ) for template in self.device_type.frontport_templates.all() - ]) DeviceBay.objects.bulk_create( - [DeviceBay(device=self, name=template.name) for template in - self.device_type.device_bay_templates.all()] + [x.instantiate(self) for x in self.device_type.device_bay_templates.all()] ) # Update Site and Rack assignment for any child Devices From 605be30fb2d0107b9536f8a1f99716f58417ccc7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Aug 2019 17:48:12 -0400 Subject: [PATCH 02/71] Add test for device component creation --- netbox/dcim/tests/test_models.py | 132 ++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index e0af86b20..2135aba66 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,6 +1,5 @@ from django.test import TestCase -from dcim.constants import * from dcim.models import * @@ -152,6 +151,137 @@ class RackTestCase(TestCase): self.assertTrue(pdu) +class DeviceTestCase(TestCase): + + def setUp(self): + + self.site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.device_role = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + + # Create DeviceType components + ConsolePortTemplate( + device_type=self.device_type, + name='Console Port 1' + ).save() + + ConsoleServerPortTemplate( + device_type=self.device_type, + name='Console Server Port 1' + ).save() + + ppt = PowerPortTemplate( + device_type=self.device_type, + name='Power Port 1', + maximum_draw=1000, + allocated_draw=500 + ) + ppt.save() + + PowerOutletTemplate( + device_type=self.device_type, + name='Power Outlet 1', + power_port=ppt, + feed_leg=POWERFEED_LEG_A + ).save() + + InterfaceTemplate( + device_type=self.device_type, + name='Interface 1', + type=IFACE_TYPE_1GE_FIXED, + mgmt_only=True + ).save() + + rpt = RearPortTemplate( + device_type=self.device_type, + name='Rear Port 1', + type=PORT_TYPE_8P8C, + positions=8 + ) + rpt.save() + + FrontPortTemplate( + device_type=self.device_type, + name='Front Port 1', + type=PORT_TYPE_8P8C, + rear_port=rpt, + rear_port_position=2 + ).save() + + DeviceBayTemplate( + device_type=self.device_type, + name='Device Bay 1' + ).save() + + def test_device_creation(self): + """ + Ensure that all Device components are copied automatically from the DeviceType. + """ + d = Device( + site=self.site, + device_type=self.device_type, + device_role=self.device_role, + name='Test Device 1' + ) + d.save() + + ConsolePort.objects.get( + device=d, + name='Console Port 1' + ) + + ConsoleServerPort.objects.get( + device=d, + name='Console Server Port 1' + ) + + pp = PowerPort.objects.get( + device=d, + name='Power Port 1', + maximum_draw=1000, + allocated_draw=500 + ) + + PowerOutlet.objects.get( + device=d, + name='Power Outlet 1', + power_port=pp, + feed_leg=POWERFEED_LEG_A + ) + + Interface.objects.get( + device=d, + name='Interface 1', + type=IFACE_TYPE_1GE_FIXED, + mgmt_only=True + ) + + rp = RearPort.objects.get( + device=d, + name='Rear Port 1', + type=PORT_TYPE_8P8C, + positions=8 + ) + + FrontPort.objects.get( + device=d, + name='Front Port 1', + type=PORT_TYPE_8P8C, + rear_port=rp, + rear_port_position=2 + ) + + DeviceBay.objects.get( + device=d, + name='Device Bay 1' + ) + + class CableTestCase(TestCase): def setUp(self): From 0516aecb0314046f130c0907a619f14640e99113 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Aug 2019 17:49:54 -0400 Subject: [PATCH 03/71] Changelog for #3405 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d5aa674..eaf8cd930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v2.6.3 (FUTURE) + +## Bug Fixes + +* [#3405](https://github.com/netbox-community/netbox/issues/3405) - Fix population of power port/outlet details on device creation + +--- + v2.6.2 (2019-08-02) ## Enhancements From ef432754ee322ad4046ec4d1dff0b0607eb247a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Aug 2019 21:19:14 -0400 Subject: [PATCH 04/71] Started 2.7 branch --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 090122e37..55f45565d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.6.3-dev' +VERSION = '2.7-beta1' # Hostname HOSTNAME = platform.node() From dccda62f2d52e71d4130daeda4c098bcbf12d956 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Aug 2019 21:33:20 -0400 Subject: [PATCH 05/71] Closes #2745: Remove topology maps --- base_requirements.txt | 4 - docs/additional-features/topology-maps.md | 17 -- docs/installation/2-netbox.md | 4 +- mkdocs.yml | 1 - netbox/dcim/views.py | 4 +- netbox/extras/admin.py | 14 +- netbox/extras/api/serializers.py | 15 +- netbox/extras/api/urls.py | 3 - netbox/extras/api/views.py | 34 +--- netbox/extras/constants.py | 10 -- netbox/extras/filters.py | 20 +-- .../migrations/0024_remove_topology_maps.py | 16 ++ netbox/extras/models.py | 153 +----------------- netbox/netbox/views.py | 3 +- netbox/templates/dcim/site.html | 19 --- netbox/templates/home.html | 23 --- requirements.txt | 1 - 17 files changed, 26 insertions(+), 315 deletions(-) delete mode 100644 docs/additional-features/topology-maps.md create mode 100644 netbox/extras/migrations/0024_remove_topology_maps.py diff --git a/base_requirements.txt b/base_requirements.txt index f0f6cfe38..ca3f4ba6f 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -54,10 +54,6 @@ djangorestframework # https://github.com/axnsan12/drf-yasg drf-yasg[validation] -# Python interface to the graphviz graph rendering utility -# https://github.com/xflr6/graphviz -graphviz - # Simple markup language for rendering HTML # https://github.com/Python-Markdown/markdown # py-gfm requires Markdown<3.0 diff --git a/docs/additional-features/topology-maps.md b/docs/additional-features/topology-maps.md deleted file mode 100644 index 21bbe404d..000000000 --- a/docs/additional-features/topology-maps.md +++ /dev/null @@ -1,17 +0,0 @@ -# Topology Maps - -NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. - -Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure). - -To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`. - -Each line of the **device patterns** field represents a hierarchical layer within the topology map. For example, you might map a traditional network with core, distribution, and access tiers like this: - -``` -core-switch-[abcd] -dist-switch\d -access-switch\d+;oob-switch\d+ -``` - -Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 30894670e..c825c3590 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -5,14 +5,14 @@ This section of the documentation discusses installing and configuring the NetBo **Ubuntu** ```no-highlight -# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev redis-server zlib1g-dev +# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev redis-server zlib1g-dev ``` **CentOS** ```no-highlight # yum install -y epel-release -# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis +# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config redis # easy_install-3.6 pip # ln -s /usr/bin/python36 /usr/bin/python3 ``` diff --git a/mkdocs.yml b/mkdocs.yml index 99f77d06c..9cbf38484 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,7 +32,6 @@ pages: - Context Data: 'additional-features/context-data.md' - Export Templates: 'additional-features/export-templates.md' - Graphs: 'additional-features/graphs.md' - - Topology Maps: 'additional-features/topology-maps.md' - Reports: 'additional-features/reports.md' - Webhooks: 'additional-features/webhooks.md' - Change Logging: 'additional-features/change-logging.md' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5ddaf15ed..ef7666153 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -16,7 +16,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.views import ObjectConfigContextView from ipam.models import Prefix, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable @@ -207,14 +207,12 @@ class SiteView(PermissionRequiredMixin, View): 'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(), } rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks')) - topology_maps = TopologyMap.objects.filter(site=site) show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists() return render(request, 'dcim/site.html', { 'site': site, 'stats': stats, 'rack_groups': rack_groups, - 'topology_maps': topology_maps, 'show_graphs': show_graphs, }) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index d93b04037..ca469d711 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from netbox.admin import admin_site from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook +from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, Webhook def order_content_types(field): @@ -137,15 +137,3 @@ class ExportTemplateForm(forms.ModelForm): class ExportTemplateAdmin(admin.ModelAdmin): list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension'] form = ExportTemplateForm - - -# -# Topology maps -# - -@admin.register(TopologyMap, site=admin_site) -class TopologyMapAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'site'] - prepopulated_fields = { - 'slug': ['name'], - } diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index abf0d8cf5..7ab825ea5 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -10,8 +10,7 @@ from dcim.api.nested_serializers import ( from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.constants import * from extras.models import ( - ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, - Tag + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, ) from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup @@ -69,18 +68,6 @@ class ExportTemplateSerializer(ValidatedModelSerializer): ] -# -# Topology maps -# - -class TopologyMapSerializer(ValidatedModelSerializer): - site = NestedSiteSerializer() - - class Meta: - model = TopologyMap - fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] - - # # Tags # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index c135280ea..ddfe2107c 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -26,9 +26,6 @@ router.register(r'graphs', views.GraphViewSet) # Export templates router.register(r'export-templates', views.ExportTemplateViewSet) -# Topology maps -router.register(r'topology-maps', views.TopologyMapViewSet) - # Tags router.register(r'tags', views.TagViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 44e010cd2..cbc851c4d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -2,8 +2,7 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from django.db.models import Count -from django.http import Http404, HttpResponse -from django.shortcuts import get_object_or_404 +from django.http import Http404 from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response @@ -11,8 +10,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from extras import filters from extras.models import ( - ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, - Tag, + ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, ) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -115,34 +113,6 @@ class ExportTemplateViewSet(ModelViewSet): filterset_class = filters.ExportTemplateFilter -# -# Topology maps -# - -class TopologyMapViewSet(ModelViewSet): - queryset = TopologyMap.objects.select_related('site') - serializer_class = serializers.TopologyMapSerializer - filterset_class = filters.TopologyMapFilter - - @action(detail=True) - def render(self, request, pk): - - tmap = get_object_or_404(TopologyMap, pk=pk) - img_format = 'png' - - try: - data = tmap.render(img_format=img_format) - except Exception as e: - return HttpResponse( - "There was an error generating the requested graph: %s" % e - ) - - response = HttpResponse(data, content_type='image/{}'.format(img_format)) - response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format) - - return response - - # # Tags # diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index b72ae8c08..51add5a4c 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -134,16 +134,6 @@ TEMPLATE_LANGUAGE_CHOICES = ( (TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'), ) -# Topology map types -TOPOLOGYMAP_TYPE_NETWORK = 1 -TOPOLOGYMAP_TYPE_CONSOLE = 2 -TOPOLOGYMAP_TYPE_POWER = 3 -TOPOLOGYMAP_TYPE_CHOICES = ( - (TOPOLOGYMAP_TYPE_NETWORK, 'Network'), - (TOPOLOGYMAP_TYPE_CONSOLE, 'Console'), - (TOPOLOGYMAP_TYPE_POWER, 'Power'), -) - # Change log actions OBJECTCHANGE_ACTION_CREATE = 1 OBJECTCHANGE_ACTION_UPDATE = 2 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 49e879fe4..bb8c78e2e 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -5,7 +5,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap +from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag class CustomFieldFilter(django_filters.Filter): @@ -103,24 +103,6 @@ class TagFilter(django_filters.FilterSet): ) -class TopologyMapFilter(django_filters.FilterSet): - site_id = django_filters.ModelMultipleChoiceFilter( - field_name='site', - queryset=Site.objects.all(), - label='Site', - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label='Site (slug)', - ) - - class Meta: - model = TopologyMap - fields = ['name', 'slug'] - - class ConfigContextFilter(django_filters.FilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/migrations/0024_remove_topology_maps.py b/netbox/extras/migrations/0024_remove_topology_maps.py new file mode 100644 index 000000000..c019f4cec --- /dev/null +++ b/netbox/extras/migrations/0024_remove_topology_maps.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-08-09 01:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0023_fix_tag_sequences'), + ] + + operations = [ + migrations.DeleteModel( + name='TopologyMap', + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index c5df5c2e5..68889dc33 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -7,17 +7,14 @@ 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 F, Q from django.http import HttpResponse from django.template import Template, Context from django.urls import reverse -import graphviz from jinja2 import Environment from taggit.models import TagBase, GenericTaggedItemBase -from dcim.constants import CONNECTION_STATUS_CONNECTED from utilities.fields import ColorField -from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict +from utilities.utils import deepmerge, model_names_to_filter_dict from .constants import * from .querysets import ConfigContextQuerySet @@ -496,154 +493,6 @@ class ExportTemplate(models.Model): return response -# -# Topology maps -# - -class TopologyMap(models.Model): - name = models.CharField( - max_length=50, - unique=True - ) - slug = models.SlugField( - unique=True - ) - type = models.PositiveSmallIntegerField( - choices=TOPOLOGYMAP_TYPE_CHOICES, - default=TOPOLOGYMAP_TYPE_NETWORK - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.CASCADE, - related_name='topology_maps', - blank=True, - null=True - ) - device_patterns = models.TextField( - help_text='Identify devices to include in the diagram using regular ' - 'expressions, one per line. Each line will result in a new ' - 'tier of the drawing. Separate multiple regexes within a ' - 'line using semicolons. Devices will be rendered in the ' - 'order they are defined.' - ) - description = models.CharField( - max_length=100, - blank=True - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - @property - def device_sets(self): - if not self.device_patterns: - return None - return [line.strip() for line in self.device_patterns.split('\n')] - - def render(self, img_format='png'): - - from dcim.models import Device - - # Construct the graph - if self.type == TOPOLOGYMAP_TYPE_NETWORK: - G = graphviz.Graph - else: - G = graphviz.Digraph - self.graph = G() - self.graph.graph_attr['ranksep'] = '1' - seen = set() - for i, device_set in enumerate(self.device_sets): - - subgraph = G(name='sg{}'.format(i)) - subgraph.graph_attr['rank'] = 'same' - subgraph.graph_attr['directed'] = 'true' - - # Add a pseudonode for each device_set to enforce hierarchical layout - subgraph.node('set{}'.format(i), label='', shape='none', width='0') - if i: - self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis') - - # Add each device to the graph - devices = [] - for query in device_set.strip(';').split(';'): # Split regexes on semicolons - devices += Device.objects.filter(name__regex=query).select_related('device_role') - # Remove duplicate devices - devices = [d for d in devices if d.id not in seen] - seen.update([d.id for d in devices]) - for d in devices: - bg_color = '#{}'.format(d.device_role.color) - fg_color = '#{}'.format(foreground_color(d.device_role.color)) - subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans') - - # Add an invisible connection to each successive device in a set to enforce horizontal order - for j in range(0, len(devices) - 1): - subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') - - self.graph.subgraph(subgraph) - - # Compile list of all devices - device_superset = Q() - for device_set in self.device_sets: - for query in device_set.split(';'): # Split regexes on semicolons - device_superset = device_superset | Q(name__regex=query) - devices = Device.objects.filter(*(device_superset,)) - - # Draw edges depending on graph type - if self.type == TOPOLOGYMAP_TYPE_NETWORK: - self.add_network_connections(devices) - elif self.type == TOPOLOGYMAP_TYPE_CONSOLE: - self.add_console_connections(devices) - elif self.type == TOPOLOGYMAP_TYPE_POWER: - self.add_power_connections(devices) - - return self.graph.pipe(format=img_format) - - def add_network_connections(self, devices): - - from circuits.models import CircuitTermination - from dcim.models import Interface - - # Add all interface connections to the graph - connected_interfaces = Interface.objects.select_related( - '_connected_interface__device' - ).filter( - Q(device__in=devices) | Q(_connected_interface__device__in=devices), - _connected_interface__isnull=False, - pk__lt=F('_connected_interface') - ) - for interface in connected_interfaces: - style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style) - - # Add all circuits to the graph - for termination in CircuitTermination.objects.filter(term_side='A', connected_endpoint__device__in=devices): - peer_termination = termination.get_peer_termination() - if (peer_termination is not None and peer_termination.interface is not None and - peer_termination.interface.device in devices): - self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue') - - def add_console_connections(self, devices): - - from dcim.models import ConsolePort - - # Add all console connections to the graph - for cp in ConsolePort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): - style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(cp.connected_endpoint.device.name, cp.device.name, style=style) - - def add_power_connections(self, devices): - - from dcim.models import PowerPort - - # Add all power connections to the graph - for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices): - style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style) - - # # Image attachments # diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 146bba6db..9d431be41 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -21,7 +21,7 @@ from dcim.tables import ( CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable, ) -from extras.models import ObjectChange, ReportResult, TopologyMap +from extras.models import ObjectChange, ReportResult from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable @@ -222,7 +222,6 @@ class HomeView(View): return render(request, self.template_name, { 'search_form': SearchForm(), 'stats': stats, - 'topology_maps': TopologyMap.objects.filter(site__isnull=True), 'report_results': ReportResult.objects.order_by('-created')[:10], 'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50] }) diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 0e38d2967..8af48968c 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -285,25 +285,6 @@ {% endif %} -
-
- Topology Maps -
- {% if topology_maps %} - - {% for tm in topology_maps %} - - - - - {% endfor %} -
{{ tm }}{{ tm.description }}
- {% else %} -
- None -
- {% endif %} -
{% include 'inc/modal.html' with modal_name='graphs' %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 8d483568f..be63b19c5 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -259,29 +259,6 @@
-
-
- Global Topology Maps -
- {% if topology_maps and perms.extras.view_topologymap %} - - {% for tm in topology_maps %} - - - - - {% endfor %} -
{{ tm }}{{ tm.description }}
- {% elif perms.extras.view_topologymap %} -
- None found -
- {% else %} -
- No permission -
- {% endif %} -
Reports diff --git a/requirements.txt b/requirements.txt index 3ad165a4b..f2a5c84ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,6 @@ django-taggit-serializer==0.1.7 django-timezone-field==3.0 djangorestframework==3.9.4 drf-yasg[validation]==1.16.0 -graphviz==0.10.1 Jinja2==2.10.1 Markdown==2.6.11 netaddr==0.7.19 From 15b55f5e628f33926fc3f14262ecdf9b59fa8bb1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Aug 2019 21:35:59 -0400 Subject: [PATCH 06/71] Remove deprecated form_factor accessor on Interface and InterfaceTemplate --- netbox/dcim/api/serializers.py | 12 ++++-------- netbox/dcim/models.py | 32 -------------------------------- 2 files changed, 4 insertions(+), 40 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index e9526fa41..19a80a809 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -241,12 +241,10 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) - # TODO: Remove in v2.7 (backward-compatibility for form_factor) - form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'type', 'mgmt_only'] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -437,8 +435,6 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) - # TODO: Remove in v2.7 (backward-compatibility for form_factor) - form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -454,9 +450,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = Interface fields = [ - 'id', 'device', 'name', 'type', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', + 'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', + 'tagged_vlans', 'tags', 'count_ipaddresses', ] # TODO: This validation should be handled by Interface.clean() diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 4c22c9549..fb8ab1dc5 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1181,22 +1181,6 @@ class InterfaceTemplate(ComponentTemplateModel): def __str__(self): return self.name - # TODO: Remove in v2.7 - @property - def form_factor(self): - """ - Backward-compatibility for form_factor - """ - return self.type - - # TODO: Remove in v2.7 - @form_factor.setter - def form_factor(self, value): - """ - Backward-compatibility for form_factor - """ - self.type = value - def instantiate(self, device): return Interface( device=device, @@ -2342,22 +2326,6 @@ class Interface(CableTermination, ComponentModel): object_data=serialize_object(self) ).save() - # TODO: Remove in v2.7 - @property - def form_factor(self): - """ - Backward-compatibility for form_factor - """ - return self.type - - # TODO: Remove in v2.7 - @form_factor.setter - def form_factor(self, value): - """ - Backward-compatibility for form_factor - """ - self.type = value - @property def connected_endpoint(self): if self._connected_interface: From a25a27f31f4d9971db5121d8a4270afb2e665e8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Aug 2019 12:33:33 -0400 Subject: [PATCH 07/71] Initial work on custom scripts (#3415) --- .gitignore | 2 + netbox/extras/forms.py | 15 ++ netbox/extras/scripts.py | 143 ++++++++++++++++++ netbox/extras/templatetags/log_levels.py | 37 +++++ netbox/extras/urls.py | 10 +- netbox/extras/views.py | 54 ++++++- netbox/netbox/settings.py | 1 + netbox/scripts/__init__.py | 0 netbox/templates/extras/script.html | 77 ++++++++++ netbox/templates/extras/script_list.html | 40 +++++ .../extras/templatetags/log_level.html | 1 + 11 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 netbox/extras/scripts.py create mode 100644 netbox/extras/templatetags/log_levels.py create mode 100644 netbox/scripts/__init__.py create mode 100644 netbox/templates/extras/script.html create mode 100644 netbox/templates/extras/script_list.html create mode 100644 netbox/templates/extras/templatetags/log_level.html diff --git a/.gitignore b/.gitignore index d859bad28..36c6d3fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /netbox/netbox/ldap_config.py /netbox/reports/* !/netbox/reports/__init__.py +/netbox/scripts/* +!/netbox/scripts/__init__.py /netbox/static .idea /*.sh diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 261822d28..fad5a7ac2 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -380,3 +380,18 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): widget=ContentTypeSelect(), label='Object Type' ) + + +# +# Scripts +# + +class ScriptForm(BootstrapMixin, forms.Form): + + def __init__(self, vars, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Dynamically populate fields for variables + for name, var in vars: + self.fields[name] = var.as_field() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py new file mode 100644 index 000000000..7ba7edbf0 --- /dev/null +++ b/netbox/extras/scripts.py @@ -0,0 +1,143 @@ +from collections import OrderedDict +import inspect +import pkgutil + +from django import forms +from django.conf import settings + +from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING +from .forms import ScriptForm + + +# +# Script variables +# + +class ScriptVariable: + form_field = forms.CharField + + def __init__(self, label='', description=''): + + # Default field attributes + if not hasattr(self, 'field_attrs'): + self.field_attrs = {} + if label: + self.field_attrs['label'] = label + if description: + self.field_attrs['help_text'] = description + + def as_field(self): + """ + Render the variable as a Django form field. + """ + return self.form_field(**self.field_attrs) + + +class StringVar(ScriptVariable): + pass + + +class IntegerVar(ScriptVariable): + form_field = forms.IntegerField + + +class BooleanVar(ScriptVariable): + form_field = forms.BooleanField + field_attrs = { + 'required': False + } + + +class ObjectVar(ScriptVariable): + form_field = forms.ModelChoiceField + + def __init__(self, queryset, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.field_attrs['queryset'] = queryset + + +class Script: + """ + Custom scripts inherit this object. + """ + + def __init__(self): + + # Initiate the log + self.log = [] + + # Grab some info about the script + self.filename = inspect.getfile(self.__class__) + self.source = inspect.getsource(self.__class__) + + def __str__(self): + if hasattr(self, 'name'): + return self.name + return self.__class__.__name__ + + def _get_vars(self): + # TODO: This should preserve var ordering + return inspect.getmembers(self, is_variable) + + def run(self, context): + raise NotImplementedError("The script must define a run() method.") + + def as_form(self, data=None): + """ + Return a Django form suitable for populating the context data required to run this Script. + """ + vars = self._get_vars() + form = ScriptForm(vars, data) + + return form + + # Logging + + def log_debug(self, message): + self.log.append((LOG_DEFAULT, message)) + + def log_success(self, message): + self.log.append((LOG_SUCCESS, message)) + + def log_info(self, message): + self.log.append((LOG_INFO, message)) + + def log_warning(self, message): + self.log.append((LOG_WARNING, message)) + + def log_failure(self, message): + self.log.append((LOG_FAILURE, message)) + + +# +# Functions +# + +def is_script(obj): + """ + Returns True if the object is a Script. + """ + return obj in Script.__subclasses__() + + +def is_variable(obj): + """ + Returns True if the object is a ScriptVariable. + """ + return isinstance(obj, ScriptVariable) + + +def get_scripts(): + scripts = OrderedDict() + + # Iterate through all modules within the reports path. These are the user-created files in which reports are + # defined. + for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): + module = importer.find_module(module_name).load_module(module_name) + module_scripts = OrderedDict() + for name, cls in inspect.getmembers(module, is_script): + module_scripts[name] = cls + scripts[module_name] = module_scripts + + return scripts diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py new file mode 100644 index 000000000..f1a545cb9 --- /dev/null +++ b/netbox/extras/templatetags/log_levels.py @@ -0,0 +1,37 @@ +from django import template + +from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING + + +register = template.Library() + + +@register.inclusion_tag('extras/templatetags/log_level.html') +def log_level(level): + """ + Display a label indicating a syslog severity (e.g. info, warning, etc.). + """ + levels = { + LOG_DEFAULT: { + 'name': 'Default', + 'class': 'default' + }, + LOG_SUCCESS: { + 'name': 'Success', + 'class': 'success', + }, + LOG_INFO: { + 'name': 'Info', + 'class': 'info' + }, + LOG_WARNING: { + 'name': 'Warning', + 'class': 'warning' + }, + LOG_FAILURE: { + 'name': 'Failure', + 'class': 'danger' + } + } + + return levels[level] diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ad6eabe1e..7de0faf91 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -28,13 +28,17 @@ urlpatterns = [ path(r'image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path(r'image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Change logging + path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + # Reports path(r'reports/', views.ReportListView.as_view(), name='report_list'), path(r'reports//', views.ReportView.as_view(), name='report'), path(r'reports//run/', views.ReportRunView.as_view(), name='report_run'), - # Change logging - path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), - path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + # Scripts + path(r'scripts/', views.ScriptListView.as_view(), name='script_list'), + path(r'scripts///', views.ScriptView.as_view(), name='script'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6f4751619..8f9f2d282 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,8 +1,9 @@ from django import template from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType +from django.db import transaction from django.db.models import Count, Q from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render @@ -20,6 +21,7 @@ from .forms import ( ) from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports +from .scripts import get_scripts from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable @@ -355,3 +357,53 @@ class ReportRunView(PermissionRequiredMixin, View): messages.success(request, mark_safe(msg)) return redirect('extras:report', name=report.full_name) + + +# +# Scripts +# + +class ScriptListView(LoginRequiredMixin, View): + + def get(self, request): + + return render(request, 'extras/script_list.html', { + 'scripts': get_scripts(), + }) + + +class ScriptView(LoginRequiredMixin, View): + + def _get_script(self, module, name): + scripts = get_scripts() + try: + return scripts[module][name]() + except KeyError: + raise Http404 + + def get(self, request, module, name): + + script = self._get_script(module, name) + form = script.as_form() + + return render(request, 'extras/script.html', { + 'module': module, + 'script': script, + 'form': form, + }) + + def post(self, request, module, name): + + script = self._get_script(module, name) + form = script.as_form(request.POST) + + if form.is_valid(): + + with transaction.atomic(): + script.run(form.cleaned_data) + + return render(request, 'extras/script.html', { + 'module': module, + 'script': script, + 'form': form, + }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 090122e37..014b623cd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -85,6 +85,7 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') +SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') diff --git a/netbox/scripts/__init__.py b/netbox/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html new file mode 100644 index 000000000..8b4065613 --- /dev/null +++ b/netbox/templates/extras/script.html @@ -0,0 +1,77 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} +{% load log_levels %} + +{% block title %}{{ script }}{% endblock %} + +{% block content %} +
+
+ +
+
+

{{ script }}

+

{{ script.description }}

+ +
+
+ {% if script.log %} +
+
+
+
+ Script Output +
+ + + + + + + {% for level, message in script.log %} + + + + + + {% endfor %} +
LineLevelMessage
{{ forloop.counter }}{% log_level level %}{{ message }}
+
+
+
+ {% endif %} +
+
+
+ {% csrf_token %} + {% if form %} + {% render_form form %} + {% else %} +

This script does not require any input to run.

+ {% endif %} +
+ + Cancel +
+
+
+
+
+
+ {{ script.filename }} +
{{ script.source }}
+
+
+{% endblock %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html new file mode 100644 index 000000000..0189ef755 --- /dev/null +++ b/netbox/templates/extras/script_list.html @@ -0,0 +1,40 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

{% block title %}Scripts{% endblock %}

+
+
+ {% if scripts %} + {% for module, module_scripts in scripts.items %} +

{{ module|bettertitle }}

+ + + + + + + + + + {% for class_name, script in module_scripts.items %} + + + + + + {% endfor %} + +
NameDescription
+ {{ script }} + {{ script.description }}
+ {% endfor %} + {% else %} +
+

No scripts found.

+

Reports should be saved to {{ settings.SCRIPTS_ROOT }}. (This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration.)

+
+ {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/templatetags/log_level.html b/netbox/templates/extras/templatetags/log_level.html new file mode 100644 index 000000000..0787c2d46 --- /dev/null +++ b/netbox/templates/extras/templatetags/log_level.html @@ -0,0 +1 @@ + \ No newline at end of file From 9d054fb345b360257124a9fd3dadfb01b6969257 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Aug 2019 13:56:37 -0400 Subject: [PATCH 08/71] Add options for script vars; include script output --- netbox/extras/scripts.py | 69 ++++++++++++++++++++++++----- netbox/extras/views.py | 4 +- netbox/templates/extras/script.html | 6 +++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 7ba7edbf0..a4900b9a2 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -4,27 +4,37 @@ import pkgutil from django import forms from django.conf import settings +from django.core.validators import RegexValidator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from .forms import ScriptForm +class OptionalBooleanField(forms.BooleanField): + required = False + + # # Script variables # class ScriptVariable: + """ + Base model for script variables + """ form_field = forms.CharField - def __init__(self, label='', description=''): + def __init__(self, label='', description='', default=None, required=True): # Default field attributes - if not hasattr(self, 'field_attrs'): - self.field_attrs = {} + self.field_attrs = { + 'help_text': description, + 'required': required + } if label: self.field_attrs['label'] = label - if description: - self.field_attrs['help_text'] = description + if default: + self.field_attrs['initial'] = default def as_field(self): """ @@ -34,26 +44,62 @@ class ScriptVariable: class StringVar(ScriptVariable): - pass + """ + Character string representation. Can enforce minimum/maximum length and/or regex validation. + """ + def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Optional minimum/maximum lengths + if min_length: + self.field_attrs['min_length'] = min_length + if max_length: + self.field_attrs['max_length'] = max_length + + # Optional regular expression validation + if regex: + self.field_attrs['validators'] = [ + RegexValidator( + regex=regex, + message='Invalid value. Must match regex: {}'.format(regex), + code='invalid' + ) + ] class IntegerVar(ScriptVariable): + """ + Integer representation. Can enforce minimum/maximum values. + """ form_field = forms.IntegerField + def __init__(self, min_value=None, max_value=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Optional minimum/maximum values + if min_value: + self.field_attrs['min_value'] = min_value + if max_value: + self.field_attrs['max_value'] = max_value + class BooleanVar(ScriptVariable): - form_field = forms.BooleanField - field_attrs = { - 'required': False - } + """ + Boolean representation (true/false). Renders as a checkbox. + """ + form_field = OptionalBooleanField class ObjectVar(ScriptVariable): + """ + NetBox object representation. The provided QuerySet will determine the choices available. + """ form_field = forms.ModelChoiceField def __init__(self, queryset, *args, **kwargs): super().__init__(*args, **kwargs) + # Queryset for field choices self.field_attrs['queryset'] = queryset @@ -61,7 +107,6 @@ class Script: """ Custom scripts inherit this object. """ - def __init__(self): # Initiate the log @@ -80,7 +125,7 @@ class Script: # TODO: This should preserve var ordering return inspect.getmembers(self, is_variable) - def run(self, context): + def run(self, data): raise NotImplementedError("The script must define a run() method.") def as_form(self, data=None): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 8f9f2d282..21aed1471 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -396,14 +396,16 @@ class ScriptView(LoginRequiredMixin, View): script = self._get_script(module, name) form = script.as_form(request.POST) + output = None if form.is_valid(): with transaction.atomic(): - script.run(form.cleaned_data) + output = script.run(form.cleaned_data) return render(request, 'extras/script.html', { 'module': module, 'script': script, 'form': form, + 'output': output, }) diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 8b4065613..bbd949098 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -21,6 +21,9 @@ +
  • Source
  • @@ -69,6 +72,9 @@
    +
    +
    {{ output }}
    +
    {{ script.filename }}
    {{ script.source }}
    From 4fc19742ec74b8e45a0beae65c64b9ee2ede2729 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Aug 2019 15:00:06 -0400 Subject: [PATCH 09/71] Added documentation for custom scripts --- docs/additional-features/custom-scripts.md | 149 +++++++++++++++++++++ docs/configuration/optional-settings.md | 8 ++ 2 files changed, 157 insertions(+) create mode 100644 docs/additional-features/custom-scripts.md diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md new file mode 100644 index 000000000..c97137a41 --- /dev/null +++ b/docs/additional-features/custom-scripts.md @@ -0,0 +1,149 @@ +# Custom Scripts + +Custom scripting was introduced in NetBox v2.7 to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as: + +* Automatically populate new devices and cables in preparation for a new site deployment +* Create a range of new reserved prefixes or IP addresses +* Fetch data from an external source and import it to NetBox + +Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything. + +## Writing Custom Scripts + +All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity. + +``` +from extras.scripts import Script + +class MyScript(Script): + .. +``` + +Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.) + +``` +class MyScript(Script): + var1 = StringVar(...) + var2 = IntegerVar(...) + var3 = ObjectVar(...) + + def run(self, data): + ... +``` + +The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution. + +Defining variables is optional: You may create a script with only a `run()` method if no user input is needed. + +Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI. + +## Logging + +The Script object provides a set of convenient functions for recording messages at different severity levels: + +* `log_debug` +* `log_success` +* `log_info` +* `log_warning` +* `log_failure` + +Log messages are returned to the user upon execution of the script. + +## Variable Reference + +### StringVar + +Stores a string of characters (i.e. a line of text). Options include: + +* `min_length` - Minimum number of characters +* `max_length` - Maximum number of characters +* `regex` - A regular expression against which the provided value must match + +Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field. + +### IntegerVar + +Stored a numeric integer. Options include: + +* `min_value:` - Minimum value +* `max_value` - Maximum value + +### BooleanVar + +A true/false flag. This field has no options beyond the defaults. + +### ObjectVar + +A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type. + +* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) + +### Default Options + +All variables support the following default options: + +* `label` - The name of the form field +* `description` - A brief description of the field +* `default` - The field's default value +* `required` - Indicates whether the field is mandatory (default: true) + +## Example + +Below is an example script that creates new objects for a planned site. The user is prompted for three variables: + +* The name of the new site +* The device model (a filtered list of defined device types) +* The number of access switches to create + +These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects. + +``` +from django.utils.text import slugify + +from dcim.constants import * +from dcim.models import Device, DeviceRole, DeviceType, Site +from extras.scripts import Script, IntegerVar, ObjectVar, StringVar + + +class NewBranchScript(Script): + name = "New Branch" + description = "Provision a new branch site" + + site_name = StringVar( + description="Name of the new site" + ) + switch_count = IntegerVar( + description="Number of access switches to create" + ) + switch_model = ObjectVar( + description="Access switch model", + queryset = DeviceType.objects.filter( + manufacturer__name='Cisco', + model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T'] + ) + ) + + def run(self, data): + + # Create the new site + site = Site( + name=data['site_name'], + slug=slugify(data['site_name']), + status=SITE_STATUS_PLANNED + ) + site.save() + self.log_success("Created new site: {}".format(site)) + + # Create access switches + switch_role = DeviceRole.objects.get(name='Access Switch') + for i in range(1, data['switch_count'] + 1): + switch = Device( + device_type=data['switch_model'], + name='{}-switch{}'.format(site.slug, i), + site=site, + status=DEVICE_STATUS_PLANNED, + device_role=switch_role + ) + switch.save() + self.log_success("Created new switch: {}".format(switch)) +``` diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 4ebb56290..b532c9757 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -277,6 +277,14 @@ The file path to the location where custom reports will be kept. By default, thi --- +## SCRIPTS_ROOT + +Default: $BASE_DIR/netbox/scripts/ + +The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. + +--- + ## SESSION_FILE_PATH Default: None From 3f7f3f88f3ab8409e7b6c290047a718ef7ed5995 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Aug 2019 16:34:01 -0400 Subject: [PATCH 10/71] Fix form field ordering --- docs/additional-features/custom-scripts.md | 23 ++++++++++++++++-- netbox/extras/forms.py | 2 +- netbox/extras/scripts.py | 28 ++++++++++++++++++---- netbox/templates/extras/script.html | 2 +- netbox/templates/extras/script_list.html | 2 +- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index c97137a41..b4e5852e0 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -37,6 +37,24 @@ Defining variables is optional: You may create a script with only a `run()` meth Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI. +## Script Attributes + +### script_name + +This is the human-friendly names of your script. If omitted, the class name will be used. + +### script_description + +A human-friendly description of what your script does (optional). + +### script_fields + +The order in which the variable fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example: + +``` +script_fields = ['var1', 'var2', 'var3'] +``` + ## Logging The Script object provides a set of convenient functions for recording messages at different severity levels: @@ -106,8 +124,9 @@ from extras.scripts import Script, IntegerVar, ObjectVar, StringVar class NewBranchScript(Script): - name = "New Branch" - description = "Provision a new branch site" + script_name = "New Branch" + script_description = "Provision a new branch site" + script_fields = ['site_name', 'switch_count', 'switch_model'] site_name = StringVar( description="Name of the new site" diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index fad5a7ac2..15c91a880 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -393,5 +393,5 @@ class ScriptForm(BootstrapMixin, forms.Form): super().__init__(*args, **kwargs) # Dynamically populate fields for variables - for name, var in vars: + for name, var in vars.items(): self.fields[name] = var.as_field() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index a4900b9a2..7ef3dde2f 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -10,6 +10,15 @@ from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARN from .forms import ScriptForm +__all__ = [ + 'Script', + 'StringVar', + 'IntegerVar', + 'BooleanVar', + 'ObjectVar', +] + + class OptionalBooleanField(forms.BooleanField): required = False @@ -117,13 +126,24 @@ class Script: self.source = inspect.getsource(self.__class__) def __str__(self): - if hasattr(self, 'name'): - return self.name + if hasattr(self, 'script_name'): + return self.script_name return self.__class__.__name__ def _get_vars(self): - # TODO: This should preserve var ordering - return inspect.getmembers(self, is_variable) + vars = OrderedDict() + + # Infer order from script_fields (Python 3.5 and lower) + if hasattr(self, 'script_fields'): + for name in self.script_fields: + vars[name] = getattr(self, name) + + # Default to order of declaration on class + for name, attr in self.__class__.__dict__.items(): + if name not in vars and issubclass(attr.__class__, ScriptVariable): + vars[name] = attr + + return vars def run(self, data): raise NotImplementedError("The script must define a run() method.") diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index bbd949098..66beeb852 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -16,7 +16,7 @@

    {{ script }}

    -

    {{ script.description }}

    +

    {{ script.script_description }}

    - {% if execution_time %} + {% if execution_time or script.log %}
    From 7f65e009a8b42e2611583a5eb35ef3a80239548a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Aug 2019 13:08:21 -0400 Subject: [PATCH 27/71] Add convenience functions for loading YAML/JSON data from file --- netbox/extras/scripts.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index fac44a530..47bd8284c 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -1,7 +1,10 @@ from collections import OrderedDict import inspect +import json +import os import pkgutil import time +import yaml from django import forms from django.conf import settings @@ -196,6 +199,28 @@ class Script: def log_failure(self, message): self.log.append((LOG_FAILURE, message)) + # Convenience functions + + def load_yaml(self, filename): + """ + Return data from a YAML file + """ + file_path = os.path.join(settings.SCRIPTS_ROOT, filename) + with open(file_path, 'r') as datafile: + data = yaml.load(datafile) + + return data + + def load_json(self, filename): + """ + Return data from a JSON file + """ + file_path = os.path.join(settings.SCRIPTS_ROOT, filename) + with open(file_path, 'r') as datafile: + data = json.load(datafile) + + return data + # # Functions From 8bd1fad7d0022db84cce9d137960cc977dee9f7c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Aug 2019 14:03:11 -0400 Subject: [PATCH 28/71] Use TreeNodeChoiceField for MPTT objects --- netbox/extras/scripts.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 47bd8284c..cffb5e59d 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -10,6 +10,8 @@ from django import forms from django.conf import settings from django.core.validators import RegexValidator from django.db import transaction +from mptt.forms import TreeNodeChoiceField +from mptt.models import MPTTModel from ipam.formfields import IPFormField from utilities.exceptions import AbortTransaction @@ -124,6 +126,10 @@ class ObjectVar(ScriptVariable): # Queryset for field choices self.field_attrs['queryset'] = queryset + # Update form field for MPTT (nested) objects + if issubclass(queryset.model, MPTTModel): + self.form_field = TreeNodeChoiceField + class IPNetworkVar(ScriptVariable): """ From 434e656e277fc4f29fe8908f92c25f4225594bb8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Aug 2019 14:26:13 -0400 Subject: [PATCH 29/71] Include stack trace when catching an exception --- netbox/extras/scripts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index cffb5e59d..206a53ec4 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -3,7 +3,9 @@ import inspect import json import os import pkgutil +import sys import time +import traceback import yaml from django import forms @@ -265,8 +267,9 @@ def run_script(script, data, commit=True): except AbortTransaction: pass except Exception as e: + stacktrace = traceback.format_exc() script.log_failure( - "An exception occurred. {}: {}".format(type(e).__name__, e) + "An exception occurred. {}: {}\n```{}```".format(type(e).__name__, e, stacktrace) ) commit = False finally: From f8326ef6df07544b5ed8e7085824524db2c325a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Aug 2019 14:38:11 -0400 Subject: [PATCH 30/71] Add markdown rendering for log mesages --- netbox/extras/scripts.py | 2 +- netbox/project-static/css/base.css | 3 +++ netbox/templates/extras/script.html | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 206a53ec4..156d0a4bc 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -269,7 +269,7 @@ def run_script(script, data, commit=True): except Exception as e: stacktrace = traceback.format_exc() script.log_failure( - "An exception occurred. {}: {}\n```{}```".format(type(e).__name__, e, stacktrace) + "An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace) ) commit = False finally: diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index fcee05e12..93e2188ba 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -529,6 +529,9 @@ table.report th a { border-top: 1px solid #dddddd; padding: 8px; } +.rendered-markdown :last-child { + margin-bottom: 0; +} /* AJAX loader */ .loading { diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 7a9ddb665..ae1f89b49 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -47,7 +47,7 @@ {{ forloop.counter }} {% log_level level %} - {{ message }} + {{ message|gfm }} {% empty %} From 47d60dbb20584d0c6b8dbdb047f88befb6c77941 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Aug 2019 15:46:08 -0400 Subject: [PATCH 31/71] Fix table column widths --- netbox/templates/extras/script_list.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 3230ab714..5e115fba2 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -4,16 +4,15 @@ {% block content %}

    {% block title %}Scripts{% endblock %}

    -
    +
    {% if scripts %} {% for module, module_scripts in scripts.items %}

    {{ module|bettertitle }}

    - - - + + @@ -23,7 +22,6 @@ {{ script }} - {% endfor %} From cb0dbc0769c22b41d440d2b2cc50a7acfa23bf01 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Aug 2019 16:20:52 -0400 Subject: [PATCH 32/71] Add TextVar for large text entry --- netbox/extras/scripts.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 156d0a4bc..2a0c0db7b 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -3,7 +3,6 @@ import inspect import json import os import pkgutil -import sys import time import traceback import yaml @@ -24,6 +23,7 @@ from .forms import ScriptForm __all__ = [ 'Script', 'StringVar', + 'TextVar', 'IntegerVar', 'BooleanVar', 'ObjectVar', @@ -87,6 +87,18 @@ class StringVar(ScriptVariable): ] +class TextVar(ScriptVariable): + """ + Free-form text data. Renders as a
    NameDescriptionNameDescription
    {{ script.Meta.description }}