From 7a41b02fdd87e0b835ebeaf41851681a5ec33f8e Mon Sep 17 00:00:00 2001 From: mmahacek Date: Fri, 28 Jun 2019 17:03:06 -0700 Subject: [PATCH 01/87] Add line for PowerPanel count --- netbox/templates/home.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index bc00d4a28..8d483568f 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -115,6 +115,16 @@ {% endif %}

Electrical circuits delivering power from panels

+
+ {% if perms.dcim.view_powerpanel %} + {{ stats.powerpanel_count }} +

Power Panels

+ {% else %} + +

Power Panels

+ {% endif %} +

Electrical panels receiving utility power

+
From ddced4fc2b41ec5e88116991d4c0ab392df67af7 Mon Sep 17 00:00:00 2001 From: mmahacek Date: Fri, 28 Jun 2019 17:04:42 -0700 Subject: [PATCH 02/87] Add stats.powerpanel_count --- netbox/netbox/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 9d382592d..146bba6db 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -15,7 +15,7 @@ from dcim.filters import ( VirtualChassisFilter, ) from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis + Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis ) from dcim.tables import ( CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable, @@ -196,6 +196,7 @@ class HomeView(View): 'cable_count': cables.count(), 'console_connections_count': connected_consoleports.count(), 'power_connections_count': connected_powerports.count(), + 'powerpanel_count': PowerPanel.objects.count(), 'powerfeed_count': PowerFeed.objects.count(), # IPAM From 118ec358c0c7fdfd44a70e4a757fa47362219163 Mon Sep 17 00:00:00 2001 From: Lasse Bang Mikkelsen Date: Thu, 4 Jul 2019 17:28:25 +0200 Subject: [PATCH 03/87] Fixes #3324: Doc incorrectly states child devices shown as non-racked --- docs/core-functionality/devices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 51486f7c1..d170b374e 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -95,7 +95,7 @@ Pass-through ports can also be used to model "bump in the wire" devices, such as ### Device Bays -Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. +Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations or the "Non-Racked Devices" list within the rack view. Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices. From 6276f5f7b9499cc450cbbd3b01a91418987a10a3 Mon Sep 17 00:00:00 2001 From: Lasse Bang Mikkelsen Date: Thu, 4 Jul 2019 17:37:28 +0200 Subject: [PATCH 04/87] Fixes #3323: Interface Connections view inaccessible with "dcim.view_interface" permission --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cf152e646..5ddaf15ed 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1903,7 +1903,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.interface' + permission_required = 'dcim.view_interface' queryset = Interface.objects.select_related( 'device', 'cable', '_connected_interface__device' ).filter( From 88b176ae15613a0efa34bac5c17d154ccee6cea5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Jul 2019 16:22:30 -0400 Subject: [PATCH 05/87] Changelog for #3307 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 115b5306b..dd8ad8ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ v2.6.2 (FUTURE) ## Enhancements * [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side +* [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page ## Bug Fixes From 376eae748cf20343e881967be63426cc9ac37e85 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Jul 2019 16:25:19 -0400 Subject: [PATCH 06/87] Changelog for #3323 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8ad8ea8..c7ec8b446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ v2.6.2 (FUTURE) ## Bug Fixes * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView +* [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view * [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button --- From 71551893b117e9a7ca6900bbee262424516677e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Jul 2019 20:42:15 -0400 Subject: [PATCH 07/87] Fixes #3293: Enable filtering device components by multiple device IDs --- CHANGELOG.md | 1 + netbox/dcim/filters.py | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ec8b446..d02babd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ v2.6.2 (FUTURE) ## Bug Fixes +* [#3293](https://github.com/netbox-community/netbox/issues/3293) - Enable filtering device components by multiple device IDs * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView * [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view * [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 6312fd0d5..911f3c0a3 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -9,7 +9,9 @@ from extras.filters import CustomFieldFilterSet from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import ( + MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, +) from virtualization.models import Cluster from .constants import * from .models import ( @@ -624,7 +626,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet): method='search', label='Search', ) - device_id = django_filters.ModelChoiceFilter( + device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', ) @@ -705,8 +707,8 @@ class InterfaceFilter(django_filters.FilterSet): field_name='name', label='Device', ) - device_id = django_filters.NumberFilter( - method='filter_device', + device_id = MultiValueNumberFilter( + method='filter_device_id', field_name='pk', label='Device (ID)', ) @@ -762,6 +764,17 @@ class InterfaceFilter(django_filters.FilterSet): except Device.DoesNotExist: return queryset.none() + def filter_device_id(self, queryset, name, id_list): + # Include interfaces belonging to peer virtual chassis members + vc_interface_ids = [] + try: + devices = Device.objects.filter(pk__in=id_list) + for device in devices: + vc_interface_ids += device.vc_interfaces.values_list('id', flat=True) + return queryset.filter(pk__in=vc_interface_ids) + except Device.DoesNotExist: + return queryset.none() + def filter_vlan_id(self, queryset, name, value): value = value.strip() if not value: From 86b6b9bf8b873aff5ecb717918a5fed6dc265c64 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Jul 2019 21:21:56 -0400 Subject: [PATCH 08/87] Fixes #3315: Enable filtering devices/interfaces by multiple MAC addresses --- CHANGELOG.md | 1 + netbox/dcim/filters.py | 34 +++++----------------------------- netbox/dcim/forms.py | 24 ++++++++++++++++++++++++ netbox/utilities/filters.py | 9 +++++++++ 4 files changed, 39 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d02babd3d..cdaca9e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ v2.6.2 (FUTURE) ## Bug Fixes * [#3293](https://github.com/netbox-community/netbox/issues/3293) - Enable filtering device components by multiple device IDs +* [#3315](https://github.com/netbox-community/netbox/issues/3315) - Enable filtering devices/interfaces by multiple MAC addresses * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView * [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view * [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 911f3c0a3..a063a6b83 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -2,15 +2,14 @@ import django_filters from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q -from netaddr import EUI -from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES from utilities.filters import ( - MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .constants import * @@ -516,8 +515,8 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): field_name='device_type__is_full_depth', label='Is full depth', ) - mac_address = django_filters.CharFilter( - method='_mac_address', + mac_address = MultiValueMACAddressFilter( + field_name='interfaces__mac_address', label='MAC address', ) has_primary_ip = django_filters.BooleanFilter( @@ -574,16 +573,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): Q(comments__icontains=value) ).distinct() - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(interfaces__mac_address=mac).distinct() - except AddrFormatError: - return queryset.none() - def _has_primary_ip(self, queryset, name, value): if value: return queryset.filter( @@ -726,10 +715,7 @@ class InterfaceFilter(django_filters.FilterSet): queryset=Interface.objects.all(), label='LAG interface (ID)', ) - mac_address = django_filters.CharFilter( - method='_mac_address', - label='MAC address', - ) + mac_address = MultiValueMACAddressFilter() tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', @@ -801,16 +787,6 @@ class InterfaceFilter(django_filters.FilterSet): 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(mac_address=mac) - except AddrFormatError: - return queryset.none() - class FrontPortFilter(DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 06e1b35b1..d2628ab50 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,6 +7,8 @@ from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from mptt.forms import TreeNodeChoiceField +from netaddr import EUI +from netaddr.core import AddrFormatError from taggit.forms import TagField from timezone_field import TimeZoneFormField @@ -76,6 +78,28 @@ class BulkRenameForm(forms.Form): }) +# +# Fields +# + +class MACAddressField(forms.Field): + widget = forms.CharField + default_error_messages = { + 'invalid': 'MAC address must be in EUI-48 format', + } + + def to_python(self, value): + value = super().to_python(value) + + # Validate MAC address format + try: + value = EUI(value.strip()) + except AddrFormatError: + raise forms.ValidationError(self.error_messages['invalid'], code='invalid') + + return value + + # # Regions # diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 8ccdf2583..7ba008c70 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,6 +3,7 @@ from django import forms from django.conf import settings from django.db import models +from dcim.forms import MACAddressField from extras.models import Tag @@ -49,6 +50,14 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.TimeField) +class MACAddressFilter(django_filters.CharFilter): + field_class = MACAddressField + + +class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(MACAddressField) + + class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] From cab3c50ae6f4dfea3ddea7e66079f4d9ad4e5074 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Jul 2019 21:40:36 -0400 Subject: [PATCH 09/87] Closes #3314: Paginate object changelog entries --- CHANGELOG.md | 1 + netbox/extras/views.py | 9 ++++++++- netbox/templates/extras/object_changelog.html | 5 +++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdaca9e48..10fb77ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ v2.6.2 (FUTURE) * [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side * [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page +* [#3314](https://github.com/netbox-community/netbox/issues/3314) - Paginate object changelog entries ## Bug Fixes diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 995c024ce..6f4751619 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -228,6 +228,13 @@ class ObjectChangeLogView(View): orderable=False ) + # Apply the request context + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(objectchanges_table) + # Check whether a header template exists for this model base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name) try: @@ -239,7 +246,7 @@ class ObjectChangeLogView(View): return render(request, 'extras/object_changelog.html', { object_var: obj, - 'objectchanges_table': objectchanges_table, + 'table': objectchanges_table, 'base_template': base_template, 'active_tab': 'changelog', }) diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html index 857b56e7c..4a5dabada 100644 --- a/netbox/templates/extras/object_changelog.html +++ b/netbox/templates/extras/object_changelog.html @@ -4,9 +4,10 @@ {% block content %} {% if obj %}

{{ obj }}

{% endif %} - {% include 'panel_table.html' with table=objectchanges_table %} + {% include 'panel_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% if settings.CHANGELOG_RETENTION %} -
+
Changelog retention: {{ settings.CHANGELOG_RETENTION }} days
{% endif %} From 890ba3ea9423cf8992fd2ea9a9be83c46205b4ae Mon Sep 17 00:00:00 2001 From: Justin L R Graham Date: Tue, 23 Jul 2019 13:46:55 -0500 Subject: [PATCH 10/87] Indicate when changelog retention configured to be forever. --- netbox/templates/extras/object_changelog.html | 2 +- netbox/templates/extras/objectchange_list.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html index 4a5dabada..a286b508c 100644 --- a/netbox/templates/extras/object_changelog.html +++ b/netbox/templates/extras/object_changelog.html @@ -8,7 +8,7 @@ {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% if settings.CHANGELOG_RETENTION %}
- Changelog retention: {{ settings.CHANGELOG_RETENTION }} days + Changelog retention: {% if settings.CHANGELOG_RETENTION == 0 %}Indefinite{% else %}{{ settings.CHANGELOG_RETENTION }} days{% endif %}
{% endif %} {% endblock %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/extras/objectchange_list.html index 77b66f6b6..714689ff0 100644 --- a/netbox/templates/extras/objectchange_list.html +++ b/netbox/templates/extras/objectchange_list.html @@ -11,7 +11,7 @@ {% include 'utilities/obj_table.html' %} {% if settings.CHANGELOG_RETENTION %}
- Changelog retention: {{ settings.CHANGELOG_RETENTION }} days + Changelog retention: {% if settings.CHANGELOG_RETENTION == 0 %}Indefinite{% else %}{{ settings.CHANGELOG_RETENTION }} days{% endif %}
{% endif %}
From bcc7daeac76c4001ca2b85969f7ae469cf8e721d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 24 Jul 2019 12:22:15 -0500 Subject: [PATCH 11/87] Fixes #3370 - Add filter class to VirtualChassis API --- CHANGELOG.md | 1 + netbox/dcim/api/views.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10fb77ea1..e5e6f0110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ v2.6.2 (FUTURE) * [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side * [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page * [#3314](https://github.com/netbox-community/netbox/issues/3314) - Paginate object changelog entries +* [#3370](https://github.com/netbox-community/netbox/issues/3370) - Add filter class to VirtualChassis API ## Bug Fixes diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 05ca76479..af5ccae4a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -579,6 +579,7 @@ class VirtualChassisViewSet(ModelViewSet): member_count=Count('members') ) serializer_class = serializers.VirtualChassisSerializer + filterset_class = filters.VirtualChassisFilter # From a6c41e0be5893adf05850a8fae869d659fd1229f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jul 2019 16:26:52 -0400 Subject: [PATCH 12/87] Changelog for #3368 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e6f0110..139f3f7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ v2.6.2 (FUTURE) * [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side * [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page * [#3314](https://github.com/netbox-community/netbox/issues/3314) - Paginate object changelog entries +* [#3368](https://github.com/netbox-community/netbox/issues/3368) - Indicate indefinite changelog retention when applicable * [#3370](https://github.com/netbox-community/netbox/issues/3370) - Add filter class to VirtualChassis API ## Bug Fixes From ea32853ab33355b653302d9e65c461bcb1f44803 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jul 2019 17:07:58 -0400 Subject: [PATCH 13/87] Fixes #3289: Prevent position from being nullified when moving a device to a new rack --- CHANGELOG.md | 1 + netbox/dcim/forms.py | 2 +- netbox/project-static/js/forms.js | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139f3f7c3..0510f783f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ v2.6.2 (FUTURE) ## Bug Fixes +* [#3289](https://github.com/netbox-community/netbox/issues/3289) - Prevent position from being nullified when moving a device to a new rack * [#3293](https://github.com/netbox-community/netbox/issues/3293) - Enable filtering device components by multiple device IDs * [#3315](https://github.com/netbox-community/netbox/issues/3315) - Enable filtering devices/interfaces by multiple MAC addresses * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d2628ab50..950779fa4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1268,7 +1268,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, widget=APISelect( api_url='/api/dcim/racks/', - display_field='display_name', + display_field='display_name' ) ) position = forms.TypedChoiceField( diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index e8cc6aa1f..b1a08c7f4 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -183,7 +183,7 @@ $(document).ready(function() { // Additional query params $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-additional-query-param-")){ - var param_name = attr.name.split("data-additional-query-param-")[1] + var param_name = attr.name.split("data-additional-query-param-")[1]; parameters[param_name] = attr.value; } }); @@ -194,6 +194,8 @@ $(document).ready(function() { processResults: function (data) { var element = this.$element[0]; + // Clear any disabled options + $(element).children('option').attr('disabled', false); var results = $.map(data.results, function (obj) { obj.text = obj[element.getAttribute('display-field')] || obj.name; obj.id = obj[element.getAttribute('value-field')] || obj.id; @@ -207,7 +209,7 @@ $(document).ready(function() { // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { - var null_option = $(element).children()[0] + var null_option = $(element).children()[0]; results.unshift({ id: null_option.value, text: null_option.text From a32d185ff0308cfdbf1244e962128f8fd8dd015e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Jul 2019 10:12:07 -0400 Subject: [PATCH 14/87] Fixes #3018: Components connected via a cable must have an equal number of positions --- CHANGELOG.md | 1 + netbox/dcim/models.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0510f783f..f3e26510b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ v2.6.2 (FUTURE) ## Bug Fixes +* [#3018](https://github.com/netbox-community/netbox/issues/3018) - Components connected via a cable must have an equal number of positions * [#3289](https://github.com/netbox-community/netbox/issues/3289) - Prevent position from being nullified when moving a device to a new rack * [#3293](https://github.com/netbox-community/netbox/issues/3293) - Enable filtering device components by multiple device IDs * [#3315](https://github.com/netbox-community/netbox/issues/3315) - Enable filtering devices/interfaces by multiple MAC addresses diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a73e62ff6..0a1b52979 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2772,6 +2772,16 @@ class Cable(ChangeLoggedModel): self.termination_a_type, self.termination_b_type )) + # A component with multiple positions must be connected to a component with an equal number of positions + term_a_positions = getattr(self.termination_a, 'positions', 1) + term_b_positions = getattr(self.termination_b, 'positions', 1) + if term_a_positions != term_b_positions: + raise ValidationError( + "{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format( + self.termination_a, term_a_positions, self.termination_b, term_b_positions + ) + ) + # A termination point cannot be connected to itself if self.termination_a == self.termination_b: raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) From 2215a095c83fdcfa07056870805dca8735e1e0c2 Mon Sep 17 00:00:00 2001 From: Matt Addison Date: Thu, 1 Aug 2019 10:33:29 -0400 Subject: [PATCH 15/87] Closes #3367: Add BNC Front/Rear port types and Coaxial cable type. --- netbox/dcim/constants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 8ffc249bd..58df29914 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -280,6 +280,7 @@ IFACE_MODE_CHOICES = [ # Pass-through port types PORT_TYPE_8P8C = 1000 PORT_TYPE_110_PUNCH = 1100 +PORT_TYPE_BNC = 1200 PORT_TYPE_ST = 2000 PORT_TYPE_SC = 2100 PORT_TYPE_SC_APC = 2110 @@ -296,6 +297,7 @@ PORT_TYPE_CHOICES = [ [ [PORT_TYPE_8P8C, '8P8C'], [PORT_TYPE_110_PUNCH, '110 Punch'], + [PORT_TYPE_BNC, 'BNC'], ], ], [ @@ -376,6 +378,7 @@ CABLE_TYPE_CAT6A = 1610 CABLE_TYPE_CAT7 = 1700 CABLE_TYPE_DAC_ACTIVE = 1800 CABLE_TYPE_DAC_PASSIVE = 1810 +CABLE_TYPE_COAXIAL = 1900 CABLE_TYPE_MMF = 3000 CABLE_TYPE_MMF_OM1 = 3010 CABLE_TYPE_MMF_OM2 = 3020 @@ -397,6 +400,7 @@ CABLE_TYPE_CHOICES = ( (CABLE_TYPE_CAT7, 'CAT7'), (CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'), (CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'), + (CABLE_TYPE_COAXIAL, 'Coaxial'), ), ), ( From eb19b1a39e0f1dcbfd233e7713030954ed5e66e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Aug 2019 09:13:48 -0400 Subject: [PATCH 16/87] Changelog for #3367 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e26510b..a1b2a82d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ v2.6.2 (FUTURE) * [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side * [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page * [#3314](https://github.com/netbox-community/netbox/issues/3314) - Paginate object changelog entries +* [#3367](https://github.com/netbox-community/netbox/issues/3367) - Add BNC port type and coaxial cable type * [#3368](https://github.com/netbox-community/netbox/issues/3368) - Indicate indefinite changelog retention when applicable * [#3370](https://github.com/netbox-community/netbox/issues/3370) - Add filter class to VirtualChassis API From 025f77dcdcae31efe080e74b1c2f5ac326aa19ca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Aug 2019 09:43:46 -0400 Subject: [PATCH 17/87] Fixes #3385: Fix power panels list when bulk editing power feeds --- CHANGELOG.md | 1 + netbox/dcim/forms.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b2a82d0..19a240020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ v2.6.2 (FUTURE) * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView * [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view * [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button +* [#3385](https://github.com/netbox-community/netbox/issues/3385) - Fix power panels list when bulk editing power feeds --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 950779fa4..aaac90f3d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3638,7 +3638,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerPanel.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites", + api_url="/api/dcim/power-panels/", filter_for={ 'rackgroup': 'site_id', } From ea9492d4bdc0ea363927b9ecd3b3fe4bcd7abeee Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Aug 2019 09:56:02 -0400 Subject: [PATCH 18/87] Fixes #3384: Maximum and allocated draw fields should be included on power port template creation form --- CHANGELOG.md | 1 + netbox/dcim/forms.py | 10 ++++++++++ netbox/dcim/tables.py | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a240020..1704044f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ v2.6.2 (FUTURE) * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView * [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view * [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button +* [#3384](https://github.com/netbox-community/netbox/issues/3384) - Maximum and allocated draw fields should be included on power port template creation form * [#3385](https://github.com/netbox-community/netbox/issues/3385) - Fix power panels list when bulk editing power feeds --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index aaac90f3d..db3b78dd3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -978,6 +978,16 @@ class PowerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum current draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated current draw (watts)" + ) class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 3958e1326..de4a2dec5 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -424,7 +424,7 @@ class PowerPortTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPortTemplate - fields = ('pk', 'name') + fields = ('pk', 'name', 'maximum_draw', 'allocated_draw') empty_text = "None" From c90baaa8071ef1fb4d7a32f35cdcc19b853bfd2c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Aug 2019 10:29:10 -0400 Subject: [PATCH 19/87] Release v2.6.2 --- CHANGELOG.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1704044f6..96d5aa674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v2.6.2 (FUTURE) +v2.6.2 (2019-08-02) ## Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3ee05a24f..de2bfdb56 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.2-dev' +VERSION = '2.6.2' # Hostname HOSTNAME = platform.node() From 3a2fc43542e961c0421039e057952e4e42b6094d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Aug 2019 10:31:56 -0400 Subject: [PATCH 20/87] Post-release version bump --- 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 de2bfdb56..090122e37 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.2' +VERSION = '2.6.3-dev' # Hostname HOSTNAME = platform.node() From 068a0e22573f16fb88c6eaca6fcf5240077ed3cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Aug 2019 15:02:29 -0400 Subject: [PATCH 21/87] Removed invalid contact email --- netbox/netbox/urls.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index b08454b9a..f39040baf 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -15,7 +15,6 @@ schema_view = get_schema_view( default_version='v2', description="API to access NetBox", terms_of_service="https://github.com/netbox-community/netbox", - contact=openapi.Contact(email="netbox@digitalocean.com"), license=openapi.License(name="Apache v2 License"), ), validators=['flex', 'ssv'], From 86cd044a68fe4c0310be82c67b27888d25b39393 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Aug 2019 17:47:44 -0400 Subject: [PATCH 22/87] 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 23/87] 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 24/87] 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 a25a27f31f4d9971db5121d8a4270afb2e665e8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Aug 2019 12:33:33 -0400 Subject: [PATCH 25/87] 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 26/87] 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 27/87] 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 28/87] 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 45/87] 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 46/87] 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 47/87] 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 48/87] 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 49/87] 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 50/87] 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 }}