From 95462ce0ec30614dd4471509f9f1a2bec7ab261c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Jul 2020 09:39:15 -0400 Subject: [PATCH 01/25] 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 3216c624b..dfa0ddfff 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.7' +VERSION = '2.8.8-dev' # Hostname HOSTNAME = platform.node() From e67f08c745f5605b4d15bf204686eea5009149bc Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 2 Jul 2020 09:26:08 -0500 Subject: [PATCH 02/25] #4695 - Add metadata class to other classes --- netbox/extras/api/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 7e547dafd..472a908a1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -16,6 +16,7 @@ from extras.models import ( from extras.reports import get_report, get_reports from extras.scripts import get_script, get_scripts, run_script from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet +from utilities.metadata import ContentTypeMetadata from . import serializers @@ -88,6 +89,7 @@ class CustomFieldModelViewSet(ModelViewSet): # class GraphViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer filterset_class = filters.GraphFilterSet @@ -98,6 +100,7 @@ class GraphViewSet(ModelViewSet): # class ExportTemplateViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer filterset_class = filters.ExportTemplateFilterSet @@ -120,6 +123,7 @@ class TagViewSet(ModelViewSet): # class ImageAttachmentViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer @@ -271,6 +275,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): """ Retrieve a list of recent changes. """ + metadata_class = ContentTypeMetadata queryset = ObjectChange.objects.prefetch_related('user') serializer_class = serializers.ObjectChangeSerializer filterset_class = filters.ObjectChangeFilterSet From 20ee8ec1071c2d82501f0f50f3809038ae4d3ef0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Jul 2020 10:04:08 -0400 Subject: [PATCH 03/25] Closes #4821: Restrict group options by selected site when bulk editing VLANs --- docs/release-notes/version-2.8.md | 8 ++++++++ netbox/ipam/forms.py | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index e13e06b62..f2dc5374c 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,5 +1,13 @@ # NetBox v2.8 +## v2.8.7 (FUTURE) + +### Bug Fixes + +* [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs + +--- + ## v2.8.7 (2020-07-02) ### Enhancements diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index de7c11118..943d4d30a 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1068,7 +1068,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + widget=APISelect( + filter_for={ + 'group': 'site_id' + } + ) ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), From f807d3a024e99c02ab2246d753d30e82dbee1d19 Mon Sep 17 00:00:00 2001 From: Glenn Matthews Date: Wed, 1 Jul 2020 15:23:38 -0400 Subject: [PATCH 04/25] Don't ignore ImportErrors raised when loading a plugin. Fixes #4805 --- netbox/extras/plugins/__init__.py | 31 +++++++++++++++-------- netbox/extras/plugins/urls.py | 42 ++++++++++++++++++++----------- netbox/extras/plugins/views.py | 25 +++++++++++++----- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index ee7f59196..38fbc3b47 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,12 +1,13 @@ import collections +import importlib import inspect +import sys from packaging import version from django.apps import AppConfig from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.template.loader import get_template -from django.utils.module_loading import import_string from extras.registry import registry from utilities.choices import ButtonColorChoices @@ -60,18 +61,26 @@ class PluginConfig(AppConfig): def ready(self): # Register template content - try: - template_extensions = import_string(f"{self.__module__}.{self.template_extensions}") - register_template_extensions(template_extensions) - except ImportError: - pass + module, attr = f"{self.__module__}.{self.template_extensions}".rsplit('.', 1) + spec = importlib.util.find_spec(module) + if spec is not None: + template_content = importlib.util.module_from_spec(spec) + sys.modules[module] = template_content + spec.loader.exec_module(template_content) + if hasattr(template_content, attr): + template_extensions = getattr(template_content, attr) + register_template_extensions(template_extensions) # Register navigation menu items (if defined) - try: - menu_items = import_string(f"{self.__module__}.{self.menu_items}") - register_menu_items(self.verbose_name, menu_items) - except ImportError: - pass + module, attr = f"{self.__module__}.{self.menu_items}".rsplit('.', 1) + spec = importlib.util.find_spec(module) + if spec is not None: + navigation = importlib.util.module_from_spec(spec) + sys.modules[module] = navigation + spec.loader.exec_module(navigation) + if hasattr(navigation, attr): + menu_items = getattr(navigation, attr) + register_menu_items(self.verbose_name, menu_items) @classmethod def validate(cls, user_config): diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index b4360dc9e..d5925f1c8 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -1,9 +1,11 @@ +import importlib +import sys + from django.apps import apps from django.conf import settings from django.conf.urls import include from django.contrib.admin.views.decorators import staff_member_required from django.urls import path -from django.utils.module_loading import import_string from . import views @@ -24,19 +26,29 @@ for plugin_path in settings.PLUGINS: base_url = getattr(app, 'base_url') or app.label # Check if the plugin specifies any base URLs - try: - urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") - plugin_patterns.append( - path(f"{base_url}/", include((urlpatterns, app.label))) - ) - except ImportError: - pass + spec = importlib.util.find_spec(f"{plugin_path}.urls") + if spec is not None: + # The plugin has a .urls module - import it + urls = importlib.util.module_from_spec(spec) + sys.modules[f"{plugin_path}.urls"] = urls + spec.loader.exec_module(urls) + if hasattr(urls, "urlpatterns"): + urlpatterns = urls.urlpatterns + plugin_patterns.append( + path(f"{base_url}/", include((urlpatterns, app.label))) + ) # Check if the plugin specifies any API URLs - try: - urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") - plugin_api_patterns.append( - path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) - ) - except ImportError: - pass + spec = importlib.util.find_spec(f"{plugin_path}.api") + if spec is not None: + spec = importlib.util.find_spec(f"{plugin_path}.api.urls") + if spec is not None: + # The plugin has a .api.urls module - import it + api_urls = importlib.util.module_from_spec(spec) + sys.modules[f"{plugin_path}.api.urls"] = api_urls + spec.loader.exec_module(api_urls) + if hasattr(api_urls, "urlpatterns"): + urlpatterns = api_urls.urlpatterns + plugin_api_patterns.append( + path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) + ) diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py index 39aa692d7..cbbf28791 100644 --- a/netbox/extras/plugins/views.py +++ b/netbox/extras/plugins/views.py @@ -1,10 +1,11 @@ from collections import OrderedDict +import importlib +import sys from django.apps import apps from django.conf import settings from django.shortcuts import render from django.urls.exceptions import NoReverseMatch -from django.utils.module_loading import import_string from django.views.generic import View from rest_framework import permissions from rest_framework.response import Response @@ -60,11 +61,23 @@ class PluginsAPIRootView(APIView): @staticmethod def _get_plugin_entry(plugin, app_config, request, format): - try: - api_app_name = import_string(f"{plugin}.api.urls.app_name") - except (ImportError, ModuleNotFoundError): - # Plugin does not expose an API + # Check if the plugin specifies any API URLs + spec = importlib.util.find_spec(f"{plugin}.api") + if spec is None: + # There is no plugin.api module return None + spec = importlib.util.find_spec(f"{plugin}.api.urls") + if spec is None: + # There is no plugin.api.urls module + return None + # The plugin has a .api.urls module - import it + api_urls = importlib.util.module_from_spec(spec) + sys.modules[f"{plugin}.api.urls"] = api_urls + spec.loader.exec_module(api_urls) + if not hasattr(api_urls, "app_name"): + # The plugin api.urls does not declare an app_name string + return None + api_app_name = api_urls.app_name try: entry = (getattr(app_config, 'base_url', app_config.label), reverse( @@ -73,7 +86,7 @@ class PluginsAPIRootView(APIView): format=format )) except NoReverseMatch: - # The plugin does not include an api-root + # The plugin does not include an api-root url entry = None return entry From 5700ade1a1861fa89b3b741ef102650347faa2f1 Mon Sep 17 00:00:00 2001 From: Andrew Martin Date: Tue, 7 Jul 2020 11:12:32 -0700 Subject: [PATCH 05/25] Add NEMA 15/L15 Power Types Reference - https://www.stayonline.com/product-resources/ --- netbox/dcim/choices.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 59f30a206..1b60f00a7 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -275,6 +275,11 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_1430P = 'nema-14-30p' TYPE_NEMA_1450P = 'nema-14-50p' TYPE_NEMA_1460P = 'nema-14-60p' + TYPE_NEMA_1515P = 'nema-15-15p' + TYPE_NEMA_1520P = 'nema-15-20p' + TYPE_NEMA_1530P = 'nema-15-30p' + TYPE_NEMA_1550P = 'nema-15-50p' + TYPE_NEMA_1560P = 'nema-15-60p' # NEMA locking TYPE_NEMA_L115P = 'nema-l1-15p' TYPE_NEMA_L515P = 'nema-l5-15p' @@ -290,6 +295,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_L1430P = 'nema-l14-30p' TYPE_NEMA_L1450P = 'nema-l14-50p' TYPE_NEMA_L1460P = 'nema-l14-60p' + TYPE_NEMA_L1520P = 'nema-l15-20p' + TYPE_NEMA_L1530P = 'nema-l15-30p' + TYPE_NEMA_L1550P = 'nema-l15-50p' + TYPE_NEMA_L1560P = 'nema-l15-60p' TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2130P = 'nema-l21-30p' # California style @@ -351,6 +360,11 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_1430P, 'NEMA 14-30P'), (TYPE_NEMA_1450P, 'NEMA 14-50P'), (TYPE_NEMA_1460P, 'NEMA 14-60P'), + (TYPE_NEMA_1515P, 'NEMA 15-15P'), + (TYPE_NEMA_1520P, 'NEMA 15-20P'), + (TYPE_NEMA_1530P, 'NEMA 15-30P'), + (TYPE_NEMA_1550P, 'NEMA 15-50P'), + (TYPE_NEMA_1560P, 'NEMA 15-60P'), )), ('NEMA (Locking)', ( (TYPE_NEMA_L115P, 'NEMA L1-15P'), @@ -367,6 +381,10 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_L1430P, 'NEMA L14-30P'), (TYPE_NEMA_L1450P, 'NEMA L14-50P'), (TYPE_NEMA_L1460P, 'NEMA L14-60P'), + (TYPE_NEMA_L1520P, 'NEMA L15-20P'), + (TYPE_NEMA_L1530P, 'NEMA L15-30P'), + (TYPE_NEMA_L1550P, 'NEMA L15-50P'), + (TYPE_NEMA_L1560P, 'NEMA L15-60P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'), )), @@ -436,6 +454,11 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_1430R = 'nema-14-30r' TYPE_NEMA_1450R = 'nema-14-50r' TYPE_NEMA_1460R = 'nema-14-60r' + TYPE_NEMA_1515R = 'nema-15-15r' + TYPE_NEMA_1520R = 'nema-15-20r' + TYPE_NEMA_1530R = 'nema-15-30r' + TYPE_NEMA_1550R = 'nema-15-50r' + TYPE_NEMA_1560R = 'nema-15-60r' # NEMA locking TYPE_NEMA_L115R = 'nema-l1-15r' TYPE_NEMA_L515R = 'nema-l5-15r' @@ -451,6 +474,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_L1430R = 'nema-l14-30r' TYPE_NEMA_L1450R = 'nema-l14-50r' TYPE_NEMA_L1460R = 'nema-l14-60r' + TYPE_NEMA_L1520R = 'nema-l15-20r' + TYPE_NEMA_L1530R = 'nema-l15-30r' + TYPE_NEMA_L1550R = 'nema-l15-50r' + TYPE_NEMA_L1560R = 'nema-l15-60r' TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2130R = 'nema-l21-30r' # California style @@ -513,6 +540,11 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_1430R, 'NEMA 14-30R'), (TYPE_NEMA_1450R, 'NEMA 14-50R'), (TYPE_NEMA_1460R, 'NEMA 14-60R'), + (TYPE_NEMA_1515R, 'NEMA 15-15R'), + (TYPE_NEMA_1520R, 'NEMA 15-20R'), + (TYPE_NEMA_1530R, 'NEMA 15-30R'), + (TYPE_NEMA_1550R, 'NEMA 15-50R'), + (TYPE_NEMA_1560R, 'NEMA 15-60R'), )), ('NEMA (Locking)', ( (TYPE_NEMA_L115R, 'NEMA L1-15R'), @@ -529,6 +561,10 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_L1430R, 'NEMA L14-30R'), (TYPE_NEMA_L1450R, 'NEMA L14-50R'), (TYPE_NEMA_L1460R, 'NEMA L14-60R'), + (TYPE_NEMA_L1520R, 'NEMA L15-20R'), + (TYPE_NEMA_L1530R, 'NEMA L15-30R'), + (TYPE_NEMA_L1550R, 'NEMA L15-50R'), + (TYPE_NEMA_L1560R, 'NEMA L15-60R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'), )), From fec3ee6f08b00231ae0065702a94abce09618faa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Jul 2020 12:50:12 -0400 Subject: [PATCH 06/25] Closes #4835: Support passing multiple initial values for multiple choice fields --- docs/release-notes/version-2.8.md | 1 + netbox/utilities/forms.py | 32 +++++++++++++++++----------- netbox/utilities/tests/test_utils.py | 20 +++++++++++------ netbox/utilities/utils.py | 18 ++++++++++++++++ netbox/utilities/views.py | 4 ++-- 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index f2dc5374c..7b6d545ee 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs +* [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields --- diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 0cc928e83..9ed0cca5c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -594,21 +594,24 @@ class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter widget = APISelect - def _get_initial_value(self, initial_data, field_name): - return initial_data.get(field_name) + def filter_queryset(self, data): + field_name = getattr(self, 'to_field_name') or 'pk' + # If multiple values have been provided, use only the last. + if type(data) in (list, tuple): + data = data[-1] + filter = self.filter( + field_name=field_name + ) + return filter.filter(self.queryset, data) def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) - # Override initial() to allow passing multiple values - bound_field.initial = self._get_initial_value(form.initial, field_name) - # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() if data: - filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset) - self.queryset = filter.filter(self.queryset, data) + self.queryset = self.filter_queryset(data) else: self.queryset = self.queryset.none() @@ -638,11 +641,16 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip filter = django_filters.ModelMultipleChoiceFilter widget = APISelectMultiple - def _get_initial_value(self, initial_data, field_name): - # If a QueryDict has been passed as initial form data, get *all* listed values - if hasattr(initial_data, 'getlist'): - return initial_data.getlist(field_name) - return initial_data.get(field_name) + def filter_queryset(self, data): + field_name = getattr(self, 'to_field_name') or 'pk' + # Normalize data to a list + if type(data) not in (list, tuple): + data = [data] + filter = self.filter( + field_name=field_name, + lookup_expr='in' + ) + return filter.filter(self.queryset, data) class LaxURLField(forms.URLField): diff --git a/netbox/utilities/tests/test_utils.py b/netbox/utilities/tests/test_utils.py index 5d9a98ad5..0a0c3ad2c 100644 --- a/netbox/utilities/tests/test_utils.py +++ b/netbox/utilities/tests/test_utils.py @@ -1,15 +1,13 @@ +from django.http import QueryDict from django.test import TestCase -from utilities.utils import deepmerge, dict_to_filter_params +from utilities.utils import deepmerge, dict_to_filter_params, normalize_querydict class DictToFilterParamsTest(TestCase): """ Validate the operation of dict_to_filter_params(). """ - def setUp(self): - return - def test_dict_to_filter_params(self): input = { @@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase): self.assertNotEqual(dict_to_filter_params(input), output) +class NormalizeQueryDictTest(TestCase): + """ + Validate normalize_querydict() utility function. + """ + def test_normalize_querydict(self): + self.assertDictEqual( + normalize_querydict(QueryDict('foo=1&bar=2&bar=3&baz=')), + {'foo': '1', 'bar': ['2', '3'], 'baz': ''} + ) + + class DeepMergeTest(TestCase): """ Validate the behavior of the deepmerge() utility. """ - def setUp(self): - return - def test_deepmerge(self): dict1 = { diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 4c07f5520..cb44a93b1 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -150,6 +150,24 @@ def dict_to_filter_params(d, prefix=''): return params +def normalize_querydict(querydict): + """ + Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example, + + QueryDict('foo=1&bar=2&bar=3&baz=') + + becomes: + + {'foo': '1', 'bar': ['2', '3'], 'baz': ''} + + This function is necessary because QueryDict does not provide any built-in mechanism which preserves multiple + values. + """ + return { + k: v if len(v) > 1 else v[0] for k, v in querydict.lists() + } + + def deepmerge(original, new): """ Deep merge two dictionaries (new into original) and return a new dict diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a4ed54b03..38fb6d963 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -27,7 +27,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm -from utilities.utils import csv_format, prepare_cloned_fields +from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm from .paginator import EnhancedPaginator, get_paginate_count @@ -250,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View): def get(self, request, *args, **kwargs): # Parse initial data manually to avoid setting field values as lists - initial_data = {k: request.GET[k] for k in request.GET} + initial_data = normalize_querydict(request.GET) form = self.model_form(instance=self.obj, initial=initial_data) return render(request, self.template_name, { From d70140f148c4972a7f1a1aa5fef4066c3c26bfdf Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Wed, 8 Jul 2020 22:20:20 +0200 Subject: [PATCH 07/25] Fix typo in format string --- netbox/dcim/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 924f14a93..ea875d8c8 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -376,7 +376,7 @@ class DeviceViewSet(CustomFieldModelViewSet): if device.platform is None: raise ServiceUnavailable("No platform is configured for this device.") if not device.platform.napalm_driver: - raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format( + raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format( device.platform )) From 683ba5eed31f22294a0d9f97408be05d67dba950 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Jul 2020 16:35:02 -0400 Subject: [PATCH 08/25] #4835: Cleanup and improved error handling --- docs/release-notes/version-2.8.md | 2 +- netbox/utilities/forms.py | 29 +++++++---------------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 7b6d545ee..05487516f 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,6 +1,6 @@ # NetBox v2.8 -## v2.8.7 (FUTURE) +## v2.8.8 (FUTURE) ### Bug Fixes diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 9ed0cca5c..539347aaa 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -594,16 +594,6 @@ class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter widget = APISelect - def filter_queryset(self, data): - field_name = getattr(self, 'to_field_name') or 'pk' - # If multiple values have been provided, use only the last. - if type(data) in (list, tuple): - data = data[-1] - filter = self.filter( - field_name=field_name - ) - return filter.filter(self.queryset, data) - def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) @@ -611,7 +601,13 @@ class DynamicModelChoiceMixin: # will be populated on-demand via the APISelect widget. data = bound_field.value() if data: - self.queryset = self.filter_queryset(data) + field_name = getattr(self, 'to_field_name') or 'pk' + filter = self.filter(field_name=field_name) + try: + self.queryset = filter.filter(self.queryset, data) + except TypeError: + # Catch any error caused by invalid initial data passed from the user + self.queryset = self.queryset.none() else: self.queryset = self.queryset.none() @@ -641,17 +637,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip filter = django_filters.ModelMultipleChoiceFilter widget = APISelectMultiple - def filter_queryset(self, data): - field_name = getattr(self, 'to_field_name') or 'pk' - # Normalize data to a list - if type(data) not in (list, tuple): - data = [data] - filter = self.filter( - field_name=field_name, - lookup_expr='in' - ) - return filter.filter(self.queryset, data) - class LaxURLField(forms.URLField): """ From a260019a7fcb41b2ae162051d45ab1e9e14cfb27 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 10 Jul 2020 15:38:54 -0400 Subject: [PATCH 09/25] #4843: Use subqueries when counting multiple types of related objects --- netbox/dcim/tables.py | 34 ++++++++++----------------------- netbox/dcim/views.py | 18 +++++++++++------ netbox/ipam/tables.py | 10 +++------- netbox/ipam/views.py | 6 +++++- netbox/virtualization/tables.py | 18 +++++++++++------ netbox/virtualization/views.py | 6 +++++- 6 files changed, 47 insertions(+), 45 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index d8cf41eaa..e7fd6ae11 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -103,20 +103,12 @@ DEVICEROLE_ACTIONS = """ {% endif %} """ -DEVICEROLE_DEVICE_COUNT = """ -{{ value }} +DEVICE_COUNT = """ +{{ value|default:0 }} """ -DEVICEROLE_VM_COUNT = """ -{{ value }} -""" - -PLATFORM_DEVICE_COUNT = """ -{{ value }} -""" - -PLATFORM_VM_COUNT = """ -{{ value }} +VM_COUNT = """ +{{ value|default:0 }} """ PLATFORM_ACTIONS = """ @@ -278,6 +270,7 @@ class RackGroupTable(BaseTable): class RackRoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = tables.TemplateColumn(COLOR_LABEL) actions = tables.TemplateColumn( @@ -704,21 +697,18 @@ class DeviceBayTemplateTable(BaseTable): class DeviceRoleTable(BaseTable): pk = ToggleColumn() device_count = tables.TemplateColumn( - template_code=DEVICEROLE_DEVICE_COUNT, - accessor=Accessor('devices.count'), - orderable=False, + template_code=DEVICE_COUNT, verbose_name='Devices' ) vm_count = tables.TemplateColumn( - template_code=DEVICEROLE_VM_COUNT, - accessor=Accessor('virtual_machines.count'), - orderable=False, + template_code=VM_COUNT, verbose_name='VMs' ) color = tables.TemplateColumn( template_code=COLOR_LABEL, verbose_name='Label' ) + vm_role = BooleanColumn() actions = tables.TemplateColumn( template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, @@ -738,15 +728,11 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() device_count = tables.TemplateColumn( - template_code=PLATFORM_DEVICE_COUNT, - accessor=Accessor('devices.count'), - orderable=False, + template_code=DEVICE_COUNT, verbose_name='Devices' ) vm_count = tables.TemplateColumn( - template_code=PLATFORM_VM_COUNT, - accessor=Accessor('virtual_machines.count'), - orderable=False, + template_code=VM_COUNT, verbose_name='VMs' ) actions = tables.TemplateColumn( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 68359fc05..748e41139 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,7 +23,7 @@ from ipam.models import Prefix, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator -from utilities.utils import csv_format +from utilities.utils import csv_format, get_subquery from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -557,9 +557,9 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ManufacturerListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_manufacturer' queryset = Manufacturer.objects.annotate( - devicetype_count=Count('device_types', distinct=True), - inventoryitem_count=Count('inventory_items', distinct=True), - platform_count=Count('platforms', distinct=True), + devicetype_count=get_subquery(DeviceType, 'manufacturer'), + inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), + platform_count=get_subquery(Platform, 'manufacturer') ) table = tables.ManufacturerTable @@ -1020,7 +1020,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_devicerole' - queryset = DeviceRole.objects.all() + queryset = DeviceRole.objects.annotate( + device_count=get_subquery(Device, 'device_role'), + vm_count=get_subquery(VirtualMachine, 'role') + ) table = tables.DeviceRoleTable @@ -1055,7 +1058,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PlatformListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_platform' - queryset = Platform.objects.all() + queryset = Platform.objects.annotate( + device_count=get_subquery(Device, 'device_role'), + vm_count=get_subquery(VirtualMachine, 'role') + ) table = tables.PlatformTable diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ca48c2951..d624ba134 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -40,11 +40,11 @@ UTILIZATION_GRAPH = """ """ ROLE_PREFIX_COUNT = """ -{{ value }} +{{ value|default:0 }} """ ROLE_VLAN_COUNT = """ -{{ value }} +{{ value|default:0 }} """ ROLE_ACTIONS = """ @@ -319,15 +319,11 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() prefix_count = tables.TemplateColumn( - accessor=Accessor('prefixes.count'), template_code=ROLE_PREFIX_COUNT, - orderable=False, verbose_name='Prefixes' ) vlan_count = tables.TemplateColumn( - accessor=Accessor('vlans.count'), template_code=ROLE_VLAN_COUNT, - orderable=False, verbose_name='VLANs' ) actions = tables.TemplateColumn( @@ -524,7 +520,7 @@ class InterfaceIPAddressTable(BaseTable): class VLANGroupTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column(linkify=True) site = tables.LinkColumn( viewname='dcim:site', args=[Accessor('site.slug')] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index e8041e8dd..fa81de77b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -9,6 +9,7 @@ from django_tables2 import RequestConfig from dcim.models import Device, Interface from utilities.paginator import EnhancedPaginator +from utilities.utils import get_subquery from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) @@ -407,7 +408,10 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'ipam.view_role' - queryset = Role.objects.all() + queryset = Role.objects.annotate( + prefix_count=get_subquery(Prefix, 'role'), + vlan_count=get_subquery(VLAN, 'role') + ) table = tables.RoleTable diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index d957e0053..6972637d0 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """ {{ record.primary_ip4.address.ip|default:"" }} """ +CLUSTER_DEVICE_COUNT = """ +{{ value|default:0 }} +""" + +CLUSTER_VM_COUNT = """ +{{ value|default:0 }} +""" + # # Cluster types @@ -94,14 +102,12 @@ class ClusterTable(BaseTable): viewname='dcim:site', args=[Accessor('site.slug')] ) - device_count = tables.Column( - accessor=Accessor('devices.count'), - orderable=False, + device_count = tables.TemplateColumn( + template_code=CLUSTER_DEVICE_COUNT, verbose_name='Devices' ) - vm_count = tables.Column( - accessor=Accessor('virtual_machines.count'), - orderable=False, + vm_count = tables.TemplateColumn( + template_code=CLUSTER_VM_COUNT, verbose_name='VMs' ) tags = TagColumn( diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0a05833f4..4da7ee313 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -10,6 +10,7 @@ from dcim.models import Device, Interface from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import Service +from utilities.utils import get_subquery from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -94,7 +95,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ClusterListView(PermissionRequiredMixin, ObjectListView): permission_required = 'virtualization.view_cluster' - queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant') + queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant').annotate( + device_count=get_subquery(Device, 'cluster'), + vm_count=get_subquery(VirtualMachine, 'cluster') + ) table = tables.ClusterTable filterset = filters.ClusterFilterSet filterset_form = forms.ClusterFilterForm From fa9ffb23ad3ad5d012dfe3469f8a87217e5e184d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 10 Jul 2020 15:59:27 -0400 Subject: [PATCH 10/25] Fixes #4838: Fix rack power utilization display for racks without devices --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/models/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 05487516f..3b18faa81 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -6,6 +6,7 @@ * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs * [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields +* [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices --- diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 4e985bc02..993de734c 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -787,7 +787,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ) if power_stats: - allocated_draw_total = sum(x['allocated_draw_total'] for x in power_stats) + allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats) available_power_total = sum(x['available_power'] for x in power_stats) return int(allocated_draw_total / available_power_total * 100) or 0 return 0 From 7788bf3ce34aaa98d83bfafdb14bc82a7079e7a5 Mon Sep 17 00:00:00 2001 From: Josh VanDeraa Date: Fri, 10 Jul 2020 15:12:25 -0500 Subject: [PATCH 11/25] Adds to NAPALM, name lookup if no primary IP address for device --- netbox/dcim/api/views.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ea875d8c8..1493caec9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,3 +1,4 @@ +import socket from collections import OrderedDict from django.conf import settings @@ -371,8 +372,6 @@ class DeviceViewSet(CustomFieldModelViewSet): Execute a NAPALM method on a Device """ device = get_object_or_404(Device, pk=pk) - if not device.primary_ip: - raise ServiceUnavailable("This device does not have a primary IP address configured.") if device.platform is None: raise ServiceUnavailable("No platform is configured for this device.") if not device.platform.napalm_driver: @@ -402,7 +401,18 @@ class DeviceViewSet(CustomFieldModelViewSet): # Connect to the device napalm_methods = request.GET.getlist('method') response = OrderedDict([(m, None) for m in napalm_methods]) - ip_address = str(device.primary_ip.address.ip) + + # Check for primary IP address from NetBox object + if device.primary_ip: + host = str(device.primary_ip.address.ip) + else: + # Attempt to complete a DNS name resolution if no primary_ip is set + try: + host = socket.gethostbyname(device.name) + except socket.gaierror: + # Name lookup failure + raise ServiceUnavailable(f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.") + username = settings.NAPALM_USERNAME password = settings.NAPALM_PASSWORD optional_args = settings.NAPALM_ARGS.copy() @@ -423,7 +433,7 @@ class DeviceViewSet(CustomFieldModelViewSet): optional_args[key.lower()] = request.headers[header] d = driver( - hostname=ip_address, + hostname=host, username=username, password=password, timeout=settings.NAPALM_TIMEOUT, @@ -432,7 +442,7 @@ class DeviceViewSet(CustomFieldModelViewSet): try: d.open() except Exception as e: - raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e)) + raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e)) # Validate and execute each specified NAPALM method for method in napalm_methods: From cac48924aeba4c1116af3e4b2cca9991e3b077f8 Mon Sep 17 00:00:00 2001 From: Josh VanDeraa Date: Fri, 10 Jul 2020 16:18:58 -0500 Subject: [PATCH 12/25] Adds verification of device.name configured --- netbox/dcim/api/views.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 1493caec9..007217e44 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -398,21 +398,22 @@ class DeviceViewSet(CustomFieldModelViewSet): if not request.user.has_perm('dcim.napalm_read'): return HttpResponseForbidden() - # Connect to the device - napalm_methods = request.GET.getlist('method') - response = OrderedDict([(m, None) for m in napalm_methods]) - # Check for primary IP address from NetBox object if device.primary_ip: host = str(device.primary_ip.address.ip) else: - # Attempt to complete a DNS name resolution if no primary_ip is set + # Raise exception for no IP address and no Name if device.name does not exist + if not device.name: + raise ServiceUnavailable("This device does not have a primary IP address or device name to lookup configured.") try: + # Attempt to complete a DNS name resolution if no primary_ip is set host = socket.gethostbyname(device.name) except socket.gaierror: # Name lookup failure raise ServiceUnavailable(f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.") + napalm_methods = request.GET.getlist('method') + response = OrderedDict([(m, None) for m in napalm_methods]) username = settings.NAPALM_USERNAME password = settings.NAPALM_PASSWORD optional_args = settings.NAPALM_ARGS.copy() @@ -432,6 +433,7 @@ class DeviceViewSet(CustomFieldModelViewSet): elif key: optional_args[key.lower()] = request.headers[header] + # Connect to the device d = driver( hostname=host, username=username, From ba8b99d3b8fb1a31781c563b9b511c383abc4731 Mon Sep 17 00:00:00 2001 From: Josh VanDeraa Date: Mon, 13 Jul 2020 08:36:15 -0500 Subject: [PATCH 13/25] Moves location of the IP address / hostname check and assignment --- netbox/dcim/api/views.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 007217e44..2aff33840 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -379,6 +379,22 @@ class DeviceViewSet(CustomFieldModelViewSet): device.platform )) + # Check for primary IP address from NetBox object + if device.primary_ip: + host = str(device.primary_ip.address.ip) + else: + # Raise exception for no IP address and no Name if device.name does not exist + if not device.name: + raise ServiceUnavailable( + "This device does not have a primary IP address or device name to lookup configured.") + try: + # Attempt to complete a DNS name resolution if no primary_ip is set + host = socket.gethostbyname(device.name) + except socket.gaierror: + # Name lookup failure + raise ServiceUnavailable( + f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.") + # Check that NAPALM is installed try: import napalm @@ -398,20 +414,6 @@ class DeviceViewSet(CustomFieldModelViewSet): if not request.user.has_perm('dcim.napalm_read'): return HttpResponseForbidden() - # Check for primary IP address from NetBox object - if device.primary_ip: - host = str(device.primary_ip.address.ip) - else: - # Raise exception for no IP address and no Name if device.name does not exist - if not device.name: - raise ServiceUnavailable("This device does not have a primary IP address or device name to lookup configured.") - try: - # Attempt to complete a DNS name resolution if no primary_ip is set - host = socket.gethostbyname(device.name) - except socket.gaierror: - # Name lookup failure - raise ServiceUnavailable(f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.") - napalm_methods = request.GET.getlist('method') response = OrderedDict([(m, None) for m in napalm_methods]) username = settings.NAPALM_USERNAME From bc7535c4d2652a652d3796c1d7f95013779a1e40 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Jul 2020 13:35:12 -0400 Subject: [PATCH 14/25] Changelog for #4829, #4831 --- docs/release-notes/version-2.8.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 3b18faa81..db4d8d3ee 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -2,6 +2,11 @@ ## v2.8.8 (FUTURE) +### Enhancements + +* [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types +* [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set + ### Bug Fixes * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs From 9c1dd159de0dd06154d8ccd23b8fec230d2010c1 Mon Sep 17 00:00:00 2001 From: Glenn Matthews Date: Mon, 13 Jul 2020 14:50:58 -0400 Subject: [PATCH 15/25] Address/prefix/aggregate family is an integer, not a string. Fixes #4803 --- netbox/ipam/api/nested_serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index abe75b261..b4313ac03 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer): class NestedAggregateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') + family = serializers.IntegerField(read_only=True) class Meta: model = models.Aggregate @@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer): class NestedPrefixSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') + family = serializers.IntegerField(read_only=True) class Meta: model = models.Prefix @@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer): class NestedIPAddressSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') + family = serializers.IntegerField(read_only=True) class Meta: model = models.IPAddress From 087ad30d3cc02f8a8678414e48f6c58f93cc3015 Mon Sep 17 00:00:00 2001 From: Glenn Matthews Date: Mon, 13 Jul 2020 15:52:35 -0400 Subject: [PATCH 16/25] Use correct serializer class for available-prefixes POST. Fixes #3240 --- netbox/ipam/api/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index c741ad0f4..6aab3a6d8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -74,6 +74,11 @@ class PrefixViewSet(CustomFieldModelViewSet): serializer_class = serializers.PrefixSerializer filterset_class = filters.PrefixFilterSet + def get_serializer_class(self): + if self.action == "available_prefixes" and self.request.method == "POST": + return serializers.PrefixLengthSerializer + return super().get_serializer_class() + @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)}) @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)}) @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) From 0fd3c838613341fc5eb5c95086cff1dcabd8808d Mon Sep 17 00:00:00 2001 From: Glenn Matthews Date: Tue, 14 Jul 2020 17:15:17 -0400 Subject: [PATCH 17/25] Refactor repeated import code --- netbox/extras/plugins/__init__.py | 28 +++++++--------------- netbox/extras/plugins/urls.py | 39 ++++++++++--------------------- netbox/extras/plugins/utils.py | 33 ++++++++++++++++++++++++++ netbox/extras/plugins/views.py | 22 ++++------------- 4 files changed, 58 insertions(+), 64 deletions(-) create mode 100644 netbox/extras/plugins/utils.py diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 38fbc3b47..159d1a023 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,7 +1,5 @@ import collections -import importlib import inspect -import sys from packaging import version from django.apps import AppConfig @@ -12,6 +10,8 @@ from django.template.loader import get_template from extras.registry import registry from utilities.choices import ButtonColorChoices +from extras.plugins.utils import import_object + # Initialize plugin registry stores registry['plugin_template_extensions'] = collections.defaultdict(list) @@ -61,26 +61,14 @@ class PluginConfig(AppConfig): def ready(self): # Register template content - module, attr = f"{self.__module__}.{self.template_extensions}".rsplit('.', 1) - spec = importlib.util.find_spec(module) - if spec is not None: - template_content = importlib.util.module_from_spec(spec) - sys.modules[module] = template_content - spec.loader.exec_module(template_content) - if hasattr(template_content, attr): - template_extensions = getattr(template_content, attr) - register_template_extensions(template_extensions) + template_extensions = import_object(f"{self.__module__}.{self.template_extensions}") + if template_extensions is not None: + register_template_extensions(template_extensions) # Register navigation menu items (if defined) - module, attr = f"{self.__module__}.{self.menu_items}".rsplit('.', 1) - spec = importlib.util.find_spec(module) - if spec is not None: - navigation = importlib.util.module_from_spec(spec) - sys.modules[module] = navigation - spec.loader.exec_module(navigation) - if hasattr(navigation, attr): - menu_items = getattr(navigation, attr) - register_menu_items(self.verbose_name, menu_items) + menu_items = import_object(f"{self.__module__}.{self.menu_items}") + if menu_items is not None: + register_menu_items(self.verbose_name, menu_items) @classmethod def validate(cls, user_config): diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index d5925f1c8..7ab293916 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -1,12 +1,11 @@ -import importlib -import sys - from django.apps import apps from django.conf import settings from django.conf.urls import include from django.contrib.admin.views.decorators import staff_member_required from django.urls import path +from extras.plugins.utils import import_object + from . import views # Initialize URL base, API, and admin URL patterns for plugins @@ -26,29 +25,15 @@ for plugin_path in settings.PLUGINS: base_url = getattr(app, 'base_url') or app.label # Check if the plugin specifies any base URLs - spec = importlib.util.find_spec(f"{plugin_path}.urls") - if spec is not None: - # The plugin has a .urls module - import it - urls = importlib.util.module_from_spec(spec) - sys.modules[f"{plugin_path}.urls"] = urls - spec.loader.exec_module(urls) - if hasattr(urls, "urlpatterns"): - urlpatterns = urls.urlpatterns - plugin_patterns.append( - path(f"{base_url}/", include((urlpatterns, app.label))) - ) + urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns") + if urlpatterns is not None: + plugin_patterns.append( + path(f"{base_url}/", include((urlpatterns, app.label))) + ) # Check if the plugin specifies any API URLs - spec = importlib.util.find_spec(f"{plugin_path}.api") - if spec is not None: - spec = importlib.util.find_spec(f"{plugin_path}.api.urls") - if spec is not None: - # The plugin has a .api.urls module - import it - api_urls = importlib.util.module_from_spec(spec) - sys.modules[f"{plugin_path}.api.urls"] = api_urls - spec.loader.exec_module(api_urls) - if hasattr(api_urls, "urlpatterns"): - urlpatterns = api_urls.urlpatterns - plugin_api_patterns.append( - path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) - ) + urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns") + if urlpatterns is not None: + plugin_api_patterns.append( + path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) + ) diff --git a/netbox/extras/plugins/utils.py b/netbox/extras/plugins/utils.py new file mode 100644 index 000000000..87240aba8 --- /dev/null +++ b/netbox/extras/plugins/utils.py @@ -0,0 +1,33 @@ +import importlib.util +import sys + + +def import_object(module_and_object): + """ + Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object". + + Returns the imported object, or None if it doesn't exist. + """ + target_module_name, object_name = module_and_object.rsplit('.', 1) + module_hierarchy = target_module_name.split('.') + + # Iterate through the module hierarchy, checking for the existence of each successive submodule. + # We have to do this rather than jumping directly to calling find_spec(target_module_name) + # because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist. + module_name = "" + for module_component in module_hierarchy: + module_name = f"{module_name}.{module_component}" if module_name else module_component + spec = importlib.util.find_spec(module_name) + if spec is None: + # No such module + return None + + # Okay, target_module_name exists. Load it if not already loaded + if target_module_name in sys.modules: + module = sys.modules[target_module_name] + else: + module = importlib.util.module_from_spec(spec) + sys.modules[target_module_name] = module + spec.loader.exec_module(module) + + return getattr(module, object_name, None) diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py index cbbf28791..f30e0f539 100644 --- a/netbox/extras/plugins/views.py +++ b/netbox/extras/plugins/views.py @@ -1,6 +1,4 @@ from collections import OrderedDict -import importlib -import sys from django.apps import apps from django.conf import settings @@ -12,6 +10,8 @@ from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView +from extras.plugins.utils import import_object + class InstalledPluginsAdminView(View): """ @@ -62,22 +62,10 @@ class PluginsAPIRootView(APIView): @staticmethod def _get_plugin_entry(plugin, app_config, request, format): # Check if the plugin specifies any API URLs - spec = importlib.util.find_spec(f"{plugin}.api") - if spec is None: - # There is no plugin.api module + api_app_name = import_object(f"{plugin}.api.urls.app_name") + if api_app_name is None: + # Plugin does not expose an API return None - spec = importlib.util.find_spec(f"{plugin}.api.urls") - if spec is None: - # There is no plugin.api.urls module - return None - # The plugin has a .api.urls module - import it - api_urls = importlib.util.module_from_spec(spec) - sys.modules[f"{plugin}.api.urls"] = api_urls - spec.loader.exec_module(api_urls) - if not hasattr(api_urls, "app_name"): - # The plugin api.urls does not declare an app_name string - return None - api_app_name = api_urls.app_name try: entry = (getattr(app_config, 'base_url', app_config.label), reverse( From 0174983208d864238c9ca47c23878a4b021038ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Jul 2020 09:15:18 -0400 Subject: [PATCH 18/25] Changelog for, #3240, #4803, #4805 --- docs/release-notes/version-2.8.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index db4d8d3ee..4e4dedcab 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -4,11 +4,14 @@ ### Enhancements +* [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors * [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types * [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set ### Bug Fixes +* [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint +* [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs * [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields * [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices From 48576919b2ed0f53d214e01b040711e5c0343d0c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Jul 2020 09:35:46 -0400 Subject: [PATCH 19/25] Closes #4854: Add staging and decommissioning statuses for sites --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/choices.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 4e4dedcab..33a9f8f20 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -7,6 +7,7 @@ * [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors * [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types * [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set +* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites ### Bug Fixes diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 1b60f00a7..4e2ef1388 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet class SiteStatusChoices(ChoiceSet): - STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' CHOICES = ( - (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), + (STATUS_STAGING, 'Staging'), + (STATUS_ACTIVE, 'Active'), + (STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_RETIRED, 'Retired'), ) From 1d0b27c99e166744f9732441a17f7d7fb88f7407 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Jul 2020 10:01:01 -0400 Subject: [PATCH 20/25] Fixes #4851: Show locally connected peer on circuit terminations --- docs/release-notes/version-2.8.md | 1 + .../templates/circuits/inc/circuit_termination.html | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 33a9f8f20..8d1057210 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -16,6 +16,7 @@ * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs * [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields * [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices +* [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations --- diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 8db715711..0c6f76b7f 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -51,10 +51,15 @@ - {% if termination.connected_endpoint %} - to {{ termination.connected_endpoint.device }} - {{ termination.connected_endpoint }} - {% endif %} + {% with peer=termination.get_cable_peer %} + to + {% if peer.device %} + {{ peer.device }} + {% elif peer.circuit %} + {{ peer.circuit }} + {% endif %} + ({{ peer }}) + {% endwith %} {% else %} {% if perms.dcim.add_cable %}
From 1f9a4405988a3cf8c278f9b9b2af75f949928404 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Jul 2020 10:09:31 -0400 Subject: [PATCH 21/25] Fixes #4856: Redirect user back to circuit after connecting a termination --- docs/release-notes/version-2.8.md | 1 + netbox/templates/circuits/inc/circuit_termination.html | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 8d1057210..b07f1d7dd 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -17,6 +17,7 @@ * [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields * [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices * [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations +* [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination --- diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 0c6f76b7f..901ba2a2b 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -68,10 +68,10 @@ Connect
From 9d243103f494923ed97538ad866526688d4809e9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Jul 2020 15:45:27 -0400 Subject: [PATCH 22/25] Fixes #4595: Ensure consistent display of non-racked and child devices on rack view --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/views.py | 5 +++-- netbox/templates/dcim/rack.html | 15 +++++++-------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index b07f1d7dd..7a782329f 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -12,6 +12,7 @@ ### Bug Fixes * [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint +* [#4595](https://github.com/netbox-community/netbox/issues/4595) - Ensure consistent display of non-racked and child devices on rack view * [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs * [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 748e41139..bb5182a83 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -399,11 +399,12 @@ class RackView(PermissionRequiredMixin, View): rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + # Get 0U and child devices located within the rack nonracked_devices = Device.objects.filter( rack=rack, - position__isnull=True, - parent_bay__isnull=True + position__isnull=True ).prefetch_related('device_type__manufacturer') + if rack.group: peer_racks = Rack.objects.filter(site=rack.site, group=rack.group) else: diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 20e31151d..124aeeeda 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -337,7 +337,7 @@ Name Role Type - Parent + Parent Device {% for device in nonracked_devices %} @@ -346,13 +346,12 @@ {{ device.device_role }} {{ device.device_type.display_name }} - - {% if device.parent_bay %} - {{ device.parent_bay }} - {% else %} - - {% endif %} - + {% if device.parent_bay %} + {{ device.parent_bay.device }} + {{ device.parent_bay }} + {% else %} + — + {% endif %} {% endfor %} From a7829a2deb4556da2f0321f2946db7c779703c2d Mon Sep 17 00:00:00 2001 From: Glenn Matthews Date: Mon, 20 Jul 2020 10:31:24 -0400 Subject: [PATCH 23/25] Treat minified/packed JS/CSS files as binary. Fixes #4862 --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index dfdb8b771..9ad1ee25e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ *.sh text eol=lf +# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable +*.min.* binary +*.map binary +*.pack.js binary From 136d3118d224945e2d9c520b457d1ce4223fad56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jul 2020 09:41:00 -0400 Subject: [PATCH 24/25] Fixes #4872: Enable filtering virtual machine interfaces by tag --- docs/release-notes/version-2.8.md | 1 + netbox/virtualization/filters.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 7a782329f..4e9dda09d 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -19,6 +19,7 @@ * [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices * [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations * [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination +* [#4872](https://github.com/netbox-community/netbox/issues/4872) - Enable filtering virtual machine interfaces by tag --- diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index a54b6ab28..5c9b64aa3 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet): mac_address = MultiValueMACAddressFilter( label='MAC address', ) + tag = TagFilter() class Meta: model = Interface From 183d475dc8b4b08aa8b65efddd62620bf5ce311b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jul 2020 12:12:22 -0400 Subject: [PATCH 25/25] Release v2.8.8 --- docs/release-notes/version-2.8.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 4e9dda09d..d3f566e59 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,6 +1,6 @@ # NetBox v2.8 -## v2.8.8 (FUTURE) +## v2.8.8 (2020-07-21) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index dfa0ddfff..ab6bba152 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.8-dev' +VERSION = '2.8.8' # Hostname HOSTNAME = platform.node()