Преглед на файлове

Merge branch 'develop' into develop-2.9

Jeremy Stretch преди 5 години
родител
ревизия
1714902f88

+ 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

+ 15 - 1
docs/release-notes/version-2.8.md

@@ -1,11 +1,25 @@
 # NetBox v2.8
 
-## v2.8.8 (FUTURE)
+## 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
 
 ---
 

+ 20 - 4
netbox/dcim/api/views.py

@@ -1,3 +1,4 @@
+import socket
 from collections import OrderedDict
 
 from django.conf import settings
@@ -388,6 +389,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
@@ -407,10 +424,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
         if not request.user.has_perm('dcim.napalm_read_device'):
             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()
@@ -430,8 +445,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,
@@ -440,7 +456,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'),
     )
 
@@ -228,6 +232,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'
@@ -243,6 +252,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
@@ -304,6 +317,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'),
@@ -320,6 +338,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'),
         )),
@@ -389,6 +411,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'
@@ -404,6 +431,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
@@ -466,6 +497,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'),
@@ -482,6 +518,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

@@ -804,7 +804,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

+ 9 - 24
netbox/dcim/tables.py

@@ -52,20 +52,12 @@ RACK_DEVICE_COUNT = """
 <a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
 """
 
-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>
 """
 
 STATUS_LABEL = """
@@ -210,6 +202,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 = ButtonsColumn(RackRole)
@@ -502,15 +495,11 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     device_count = tables.TemplateColumn(
-        template_code=DEVICEROLE_DEVICE_COUNT,
-        accessor=Accessor('devices__unrestricted__count'),
-        orderable=False,
+        template_code=DEVICE_COUNT,
         verbose_name='Devices'
     )
     vm_count = tables.TemplateColumn(
-        template_code=DEVICEROLE_VM_COUNT,
-        accessor=Accessor('virtual_machines__unrestricted__count'),
-        orderable=False,
+        template_code=VM_COUNT,
         verbose_name='VMs'
     )
     color = tables.TemplateColumn(
@@ -533,15 +522,11 @@ class DeviceRoleTable(BaseTable):
 class PlatformTable(BaseTable):
     pk = ToggleColumn()
     device_count = tables.TemplateColumn(
-        template_code=PLATFORM_DEVICE_COUNT,
-        accessor=Accessor('devices__unrestricted__count'),
-        orderable=False,
+        template_code=DEVICE_COUNT,
         verbose_name='Devices'
     )
     vm_count = tables.TemplateColumn(
-        template_code=PLATFORM_VM_COUNT,
-        accessor=Accessor('virtual_machines__unrestricted__count'),
-        orderable=False,
+        template_code=VM_COUNT,
         verbose_name='VMs'
     )
     actions = ButtonsColumn(Platform, pk_field='slug')

+ 17 - 10
netbox/dcim/views.py

@@ -22,7 +22,7 @@ from secrets.models import Secret
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.permissions import get_permission_for_model
-from utilities.utils import csv_format
+from utilities.utils import csv_format, get_subquery
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -341,13 +341,14 @@ class RackView(ObjectView):
     def get(self, request, pk):
         rack = get_object_or_404(self.queryset, pk=pk)
 
-        nonracked_devices = Device.objects.restrict(request.user, 'view').filter(
+        # 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')
 
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site)
+
         if rack.group:
             peer_racks = peer_racks.filter(group=rack.group)
         else:
@@ -474,10 +475,10 @@ class RackReservationBulkDeleteView(BulkDeleteView):
 
 class ManufacturerListView(ObjectListView):
     queryset = Manufacturer.objects.annotate(
-        devicetype_count=Count('device_types', distinct=True),
-        inventoryitem_count=Count('inventory_items', distinct=True),
-        platform_count=Count('platforms', distinct=True),
-    ).order_by(*Manufacturer._meta.ordering)
+        devicetype_count=get_subquery(DeviceType, 'manufacturer'),
+        inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
+        platform_count=get_subquery(Platform, 'manufacturer')
+    )
     table = tables.ManufacturerTable
 
 
@@ -919,7 +920,10 @@ class DeviceBayTemplateBulkDeleteView(BulkDeleteView):
 #
 
 class DeviceRoleListView(ObjectListView):
-    queryset = DeviceRole.objects.all()
+    queryset = DeviceRole.objects.annotate(
+        device_count=get_subquery(Device, 'device_role'),
+        vm_count=get_subquery(VirtualMachine, 'role')
+    )
     table = tables.DeviceRoleTable
 
 
@@ -948,7 +952,10 @@ class DeviceRoleBulkDeleteView(BulkDeleteView):
 #
 
 class PlatformListView(ObjectListView):
-    queryset = Platform.objects.all()
+    queryset = Platform.objects.annotate(
+        device_count=get_subquery(Device, 'device_role'),
+        vm_count=get_subquery(VirtualMachine, 'role')
+    )
     table = tables.PlatformTable
 
 

+ 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

@@ -73,6 +73,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'])

+ 3 - 7
netbox/ipam/tables.py

@@ -31,11 +31,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>
 """
 
 PREFIX_LINK = """
@@ -283,15 +283,11 @@ class AggregateDetailTable(AggregateTable):
 class RoleTable(BaseTable):
     pk = ToggleColumn()
     prefix_count = tables.TemplateColumn(
-        accessor=Accessor('prefixes__unrestricted__count'),
         template_code=ROLE_PREFIX_COUNT,
-        orderable=False,
         verbose_name='Prefixes'
     )
     vlan_count = tables.TemplateColumn(
-        accessor=Accessor('vlans__unrestricted__count'),
         template_code=ROLE_VLAN_COUNT,
-        orderable=False,
         verbose_name='VLANs'
     )
     actions = ButtonsColumn(Role, pk_field='slug')
@@ -474,7 +470,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

@@ -7,6 +7,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, ObjectView, ObjectDeleteView, ObjectEditView,
     ObjectListView,
@@ -285,7 +286,10 @@ class AggregateBulkDeleteView(BulkDeleteView):
 #
 
 class RoleListView(ObjectListView):
-    queryset = Role.objects.all()
+    queryset = Role.objects.annotate(
+        prefix_count=get_subquery(Prefix, 'role'),
+        vlan_count=get_subquery(VLAN, 'role')
+    )
     table = tables.RoleTable
 
 

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

+ 1 - 0
netbox/virtualization/filters.py

@@ -231,6 +231,7 @@ class VMInterfaceFilterSet(BaseFilterSet):
     mac_address = MultiValueMACAddressFilter(
         label='MAC address',
     )
+    tag = TagFilter()
 
     class Meta:
         model = VMInterface

+ 12 - 6
netbox/virtualization/tables.py

@@ -16,6 +16,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 """
 
+DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
+"""
+
+VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
+"""
+
 
 #
 # Cluster types
@@ -66,14 +74,12 @@ class ClusterTable(BaseTable):
     site = tables.Column(
         linkify=True
     )
-    device_count = tables.Column(
-        accessor=Accessor('devices__unrestricted__count'),
-        orderable=False,
+    device_count = tables.TemplateColumn(
+        template_code=DEVICE_COUNT,
         verbose_name='Devices'
     )
-    vm_count = tables.Column(
-        accessor=Accessor('virtual_machines__unrestricted__count'),
-        orderable=False,
+    vm_count = tables.TemplateColumn(
+        template_code=VM_COUNT,
         verbose_name='VMs'
     )
     tags = TagColumn(

+ 6 - 1
netbox/virtualization/views.py

@@ -9,6 +9,7 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from utilities.utils import get_subquery
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -80,7 +81,11 @@ class ClusterGroupBulkDeleteView(BulkDeleteView):
 #
 
 class ClusterListView(ObjectListView):
-    queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
+    permission_required = 'virtualization.view_cluster'
+    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