Просмотр исходного кода

Merge pull request #4873 from netbox-community/develop

Release v2.8.8
Jeremy Stretch 5 лет назад
Родитель
Сommit
f1e82a3647

+ 4 - 0
.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

+ 23 - 0
docs/release-notes/version-2.8.md

@@ -1,5 +1,28 @@
 # NetBox v2.8
 
+## v2.8.8 (2020-07-21)
+
+### 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
+* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites
+
+### 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
+* [#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
+
+---
+
 ## v2.8.7 (2020-07-02)
 
 ### Enhancements

+ 21 - 7
netbox/dcim/api/views.py

@@ -1,3 +1,4 @@
+import socket
 from collections import OrderedDict
 
 from django.conf import settings
@@ -371,15 +372,29 @@ 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:
-            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
             ))
 
+        # 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
@@ -399,10 +414,8 @@ 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])
-        ip_address = str(device.primary_ip.address.ip)
         username = settings.NAPALM_USERNAME
         password = settings.NAPALM_PASSWORD
         optional_args = settings.NAPALM_ARGS.copy()
@@ -422,8 +435,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
             elif key:
                 optional_args[key.lower()] = request.headers[header]
 
+        # Connect to the device
         d = driver(
-            hostname=ip_address,
+            hostname=host,
             username=username,
             password=password,
             timeout=settings.NAPALM_TIMEOUT,
@@ -432,7 +446,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:

+ 42 - 2
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'),
     )
 
@@ -275,6 +279,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 +299,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 +364,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 +385,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 +458,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 +478,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 +544,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 +565,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'),
         )),

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

+ 10 - 24
netbox/dcim/tables.py

@@ -103,20 +103,12 @@ DEVICEROLE_ACTIONS = """
 {% endif %}
 """
 
-DEVICEROLE_DEVICE_COUNT = """
-<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
+DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
 """
 
-DEVICEROLE_VM_COUNT = """
-<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
-"""
-
-PLATFORM_DEVICE_COUNT = """
-<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
-"""
-
-PLATFORM_VM_COUNT = """
-<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
+VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
 """
 
 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(

+ 15 - 8
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,
@@ -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:
@@ -557,9 +558,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 +1021,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 +1059,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
 
 

+ 5 - 0
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

+ 6 - 9
netbox/extras/plugins/__init__.py

@@ -6,11 +6,12 @@ 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
 
+from extras.plugins.utils import import_object
+
 
 # Initialize plugin registry stores
 registry['plugin_template_extensions'] = collections.defaultdict(list)
@@ -60,18 +61,14 @@ class PluginConfig(AppConfig):
     def ready(self):
 
         # Register template content
-        try:
-            template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
+        template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
+        if template_extensions is not None:
             register_template_extensions(template_extensions)
-        except ImportError:
-            pass
 
         # Register navigation menu items (if defined)
-        try:
-            menu_items = import_string(f"{self.__module__}.{self.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)
-        except ImportError:
-            pass
 
     @classmethod
     def validate(cls, user_config):

+ 6 - 9
netbox/extras/plugins/urls.py

@@ -3,7 +3,8 @@ 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 extras.plugins.utils import import_object
 
 from . import views
 
@@ -24,19 +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
-    try:
-        urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
+    urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns")
+    if urlpatterns is not None:
         plugin_patterns.append(
             path(f"{base_url}/", include((urlpatterns, app.label)))
         )
-    except ImportError:
-        pass
 
     # Check if the plugin specifies any API URLs
-    try:
-        urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
+    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")))
         )
-    except ImportError:
-        pass

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

+ 6 - 5
netbox/extras/plugins/views.py

@@ -4,13 +4,14 @@ 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
 from rest_framework.reverse import reverse
 from rest_framework.views import APIView
 
+from extras.plugins.utils import import_object
+
 
 class InstalledPluginsAdminView(View):
     """
@@ -60,9 +61,9 @@ 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):
+        # Check if the plugin specifies any API URLs
+        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
 
@@ -73,7 +74,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

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

+ 5 - 0
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'])

+ 6 - 1
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(),

+ 3 - 7
netbox/ipam/tables.py

@@ -40,11 +40,11 @@ UTILIZATION_GRAPH = """
 """
 
 ROLE_PREFIX_COUNT = """
-<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
+<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
 """
 
 ROLE_VLAN_COUNT = """
-<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
+<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
 """
 
 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')]

+ 5 - 1
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
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.8.7'
+VERSION = '2.8.8'
 
 # Hostname
 HOSTNAME = platform.node()

+ 13 - 8
netbox/templates/circuits/inc/circuit_termination.html

@@ -51,10 +51,15 @@
                         <a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
                             <i class="fa fa-share-alt" aria-hidden="true"></i>
                         </a>
-                        {% if termination.connected_endpoint %}
-                            to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
-                            <i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
-                        {% endif %}
+                        {% with peer=termination.get_cable_peer %}
+                            to
+                            {% if peer.device %}
+                                <a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
+                            {% elif peer.circuit %}
+                                <a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
+                            {% endif %}
+                            ({{ peer }})
+                        {% endwith %}
                     {% else %}
                         {% if perms.dcim.add_cable %}
                             <div class="pull-right">
@@ -63,10 +68,10 @@
                                         <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
                                     </button>
                                     <ul class="dropdown-menu dropdown-menu-right">
-                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
-                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
-                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
-                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
+                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Interface</a></li>
+                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Front Port</a></li>
+                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Rear Port</a></li>
+                                        <li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Circuit Termination</a></li>
                                     </ul>
                                 </span>
                             </div>

+ 7 - 8
netbox/templates/dcim/rack.html

@@ -337,7 +337,7 @@
                         <th>Name</th>
                         <th>Role</th>
                         <th>Type</th>
-                        <th>Parent</th>
+                        <th colspan="2">Parent Device</th>
                     </tr>
                     {% for device in nonracked_devices %}
                         <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
@@ -346,13 +346,12 @@
                             </td>
                             <td>{{ device.device_role }}</td>
                             <td>{{ device.device_type.display_name }}</td>
-                            <td>
-                                {% if device.parent_bay %}
-                                    <a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
-                                {% endif %}
-                            </td>
+                            {% if device.parent_bay %}
+                                <td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
+                                <td>{{ device.parent_bay }}</td>
+                            {% else %}
+                                <td colspan="2" class="text-muted">&mdash;</td>
+                            {% endif %}
                         </tr>
                     {% endfor %}
                 </table>

+ 7 - 14
netbox/utilities/forms.py

@@ -594,21 +594,20 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     widget = APISelect
 
-    def _get_initial_value(self, initial_data, field_name):
-        return initial_data.get(field_name)
-
     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)
+            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()
 
@@ -638,12 +637,6 @@ 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)
-
 
 class LaxURLField(forms.URLField):
     """

+ 13 - 7
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 = {

+ 18 - 0
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

+ 2 - 2
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, {

+ 1 - 0
netbox/virtualization/filters.py

@@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet):
     mac_address = MultiValueMACAddressFilter(
         label='MAC address',
     )
+    tag = TagFilter()
 
     class Meta:
         model = Interface

+ 12 - 6
netbox/virtualization/tables.py

@@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 """
 
+CLUSTER_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
+"""
+
+CLUSTER_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
+"""
+
 
 #
 # 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(

+ 5 - 1
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