Selaa lähdekoodia

Merge branch 'develop' into develop-2.9

Jeremy Stretch 5 vuotta sitten
vanhempi
commit
1714902f88

+ 4 - 0
.gitattributes

@@ -1 +1,5 @@
 *.sh text eol=lf
 *.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
 # 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
 ### 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
 * [#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
 * [#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 collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
@@ -388,6 +389,22 @@ class DeviceViewSet(CustomFieldModelViewSet):
                 device.platform
                 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
         # Check that NAPALM is installed
         try:
         try:
             import napalm
             import napalm
@@ -407,10 +424,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
         if not request.user.has_perm('dcim.napalm_read_device'):
         if not request.user.has_perm('dcim.napalm_read_device'):
             return HttpResponseForbidden()
             return HttpResponseForbidden()
 
 
-        # Connect to the device
         napalm_methods = request.GET.getlist('method')
         napalm_methods = request.GET.getlist('method')
         response = OrderedDict([(m, None) for m in napalm_methods])
         response = OrderedDict([(m, None) for m in napalm_methods])
-        ip_address = str(device.primary_ip.address.ip)
         username = settings.NAPALM_USERNAME
         username = settings.NAPALM_USERNAME
         password = settings.NAPALM_PASSWORD
         password = settings.NAPALM_PASSWORD
         optional_args = settings.NAPALM_ARGS.copy()
         optional_args = settings.NAPALM_ARGS.copy()
@@ -430,8 +445,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
             elif key:
             elif key:
                 optional_args[key.lower()] = request.headers[header]
                 optional_args[key.lower()] = request.headers[header]
 
 
+        # Connect to the device
         d = driver(
         d = driver(
-            hostname=ip_address,
+            hostname=host,
             username=username,
             username=username,
             password=password,
             password=password,
             timeout=settings.NAPALM_TIMEOUT,
             timeout=settings.NAPALM_TIMEOUT,
@@ -440,7 +456,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
         try:
         try:
             d.open()
             d.open()
         except Exception as e:
         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
         # Validate and execute each specified NAPALM method
         for method in napalm_methods:
         for method in napalm_methods:

+ 42 - 2
netbox/dcim/choices.py

@@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet
 
 
 class SiteStatusChoices(ChoiceSet):
 class SiteStatusChoices(ChoiceSet):
 
 
-    STATUS_ACTIVE = 'active'
     STATUS_PLANNED = 'planned'
     STATUS_PLANNED = 'planned'
+    STATUS_STAGING = 'staging'
+    STATUS_ACTIVE = 'active'
+    STATUS_DECOMMISSIONING = 'decommissioning'
     STATUS_RETIRED = 'retired'
     STATUS_RETIRED = 'retired'
 
 
     CHOICES = (
     CHOICES = (
-        (STATUS_ACTIVE, 'Active'),
         (STATUS_PLANNED, 'Planned'),
         (STATUS_PLANNED, 'Planned'),
+        (STATUS_STAGING, 'Staging'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_DECOMMISSIONING, 'Decommissioning'),
         (STATUS_RETIRED, 'Retired'),
         (STATUS_RETIRED, 'Retired'),
     )
     )
 
 
@@ -228,6 +232,11 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_NEMA_1430P = 'nema-14-30p'
     TYPE_NEMA_1430P = 'nema-14-30p'
     TYPE_NEMA_1450P = 'nema-14-50p'
     TYPE_NEMA_1450P = 'nema-14-50p'
     TYPE_NEMA_1460P = 'nema-14-60p'
     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
     # NEMA locking
     TYPE_NEMA_L115P = 'nema-l1-15p'
     TYPE_NEMA_L115P = 'nema-l1-15p'
     TYPE_NEMA_L515P = 'nema-l5-15p'
     TYPE_NEMA_L515P = 'nema-l5-15p'
@@ -243,6 +252,10 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_NEMA_L1430P = 'nema-l14-30p'
     TYPE_NEMA_L1430P = 'nema-l14-30p'
     TYPE_NEMA_L1450P = 'nema-l14-50p'
     TYPE_NEMA_L1450P = 'nema-l14-50p'
     TYPE_NEMA_L1460P = 'nema-l14-60p'
     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_L2120P = 'nema-l21-20p'
     TYPE_NEMA_L2130P = 'nema-l21-30p'
     TYPE_NEMA_L2130P = 'nema-l21-30p'
     # California style
     # California style
@@ -304,6 +317,11 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_NEMA_1430P, 'NEMA 14-30P'),
             (TYPE_NEMA_1430P, 'NEMA 14-30P'),
             (TYPE_NEMA_1450P, 'NEMA 14-50P'),
             (TYPE_NEMA_1450P, 'NEMA 14-50P'),
             (TYPE_NEMA_1460P, 'NEMA 14-60P'),
             (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)', (
         ('NEMA (Locking)', (
             (TYPE_NEMA_L115P, 'NEMA L1-15P'),
             (TYPE_NEMA_L115P, 'NEMA L1-15P'),
@@ -320,6 +338,10 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_NEMA_L1430P, 'NEMA L14-30P'),
             (TYPE_NEMA_L1430P, 'NEMA L14-30P'),
             (TYPE_NEMA_L1450P, 'NEMA L14-50P'),
             (TYPE_NEMA_L1450P, 'NEMA L14-50P'),
             (TYPE_NEMA_L1460P, 'NEMA L14-60P'),
             (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_L2120P, 'NEMA L21-20P'),
             (TYPE_NEMA_L2130P, 'NEMA L21-30P'),
             (TYPE_NEMA_L2130P, 'NEMA L21-30P'),
         )),
         )),
@@ -389,6 +411,11 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_NEMA_1430R = 'nema-14-30r'
     TYPE_NEMA_1430R = 'nema-14-30r'
     TYPE_NEMA_1450R = 'nema-14-50r'
     TYPE_NEMA_1450R = 'nema-14-50r'
     TYPE_NEMA_1460R = 'nema-14-60r'
     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
     # NEMA locking
     TYPE_NEMA_L115R = 'nema-l1-15r'
     TYPE_NEMA_L115R = 'nema-l1-15r'
     TYPE_NEMA_L515R = 'nema-l5-15r'
     TYPE_NEMA_L515R = 'nema-l5-15r'
@@ -404,6 +431,10 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_NEMA_L1430R = 'nema-l14-30r'
     TYPE_NEMA_L1430R = 'nema-l14-30r'
     TYPE_NEMA_L1450R = 'nema-l14-50r'
     TYPE_NEMA_L1450R = 'nema-l14-50r'
     TYPE_NEMA_L1460R = 'nema-l14-60r'
     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_L2120R = 'nema-l21-20r'
     TYPE_NEMA_L2130R = 'nema-l21-30r'
     TYPE_NEMA_L2130R = 'nema-l21-30r'
     # California style
     # California style
@@ -466,6 +497,11 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_NEMA_1430R, 'NEMA 14-30R'),
             (TYPE_NEMA_1430R, 'NEMA 14-30R'),
             (TYPE_NEMA_1450R, 'NEMA 14-50R'),
             (TYPE_NEMA_1450R, 'NEMA 14-50R'),
             (TYPE_NEMA_1460R, 'NEMA 14-60R'),
             (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)', (
         ('NEMA (Locking)', (
             (TYPE_NEMA_L115R, 'NEMA L1-15R'),
             (TYPE_NEMA_L115R, 'NEMA L1-15R'),
@@ -482,6 +518,10 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_NEMA_L1430R, 'NEMA L14-30R'),
             (TYPE_NEMA_L1430R, 'NEMA L14-30R'),
             (TYPE_NEMA_L1450R, 'NEMA L14-50R'),
             (TYPE_NEMA_L1450R, 'NEMA L14-50R'),
             (TYPE_NEMA_L1460R, 'NEMA L14-60R'),
             (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_L2120R, 'NEMA L21-20R'),
             (TYPE_NEMA_L2130R, 'NEMA L21-30R'),
             (TYPE_NEMA_L2130R, 'NEMA L21-30R'),
         )),
         )),

+ 1 - 1
netbox/dcim/models/__init__.py

@@ -804,7 +804,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         )
         )
 
 
         if power_stats:
         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)
             available_power_total = sum(x['available_power'] for x in power_stats)
             return int(allocated_draw_total / available_power_total * 100) or 0
             return int(allocated_draw_total / available_power_total * 100) or 0
         return 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>
 <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 = """
 STATUS_LABEL = """
@@ -210,6 +202,7 @@ class RackGroupTable(BaseTable):
 
 
 class RackRoleTable(BaseTable):
 class RackRoleTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(linkify=True)
     rack_count = tables.Column(verbose_name='Racks')
     rack_count = tables.Column(verbose_name='Racks')
     color = tables.TemplateColumn(COLOR_LABEL)
     color = tables.TemplateColumn(COLOR_LABEL)
     actions = ButtonsColumn(RackRole)
     actions = ButtonsColumn(RackRole)
@@ -502,15 +495,11 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
 class DeviceRoleTable(BaseTable):
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     device_count = tables.TemplateColumn(
     device_count = tables.TemplateColumn(
-        template_code=DEVICEROLE_DEVICE_COUNT,
-        accessor=Accessor('devices__unrestricted__count'),
-        orderable=False,
+        template_code=DEVICE_COUNT,
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
     vm_count = tables.TemplateColumn(
     vm_count = tables.TemplateColumn(
-        template_code=DEVICEROLE_VM_COUNT,
-        accessor=Accessor('virtual_machines__unrestricted__count'),
-        orderable=False,
+        template_code=VM_COUNT,
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
     color = tables.TemplateColumn(
     color = tables.TemplateColumn(
@@ -533,15 +522,11 @@ class DeviceRoleTable(BaseTable):
 class PlatformTable(BaseTable):
 class PlatformTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     device_count = tables.TemplateColumn(
     device_count = tables.TemplateColumn(
-        template_code=PLATFORM_DEVICE_COUNT,
-        accessor=Accessor('devices__unrestricted__count'),
-        orderable=False,
+        template_code=DEVICE_COUNT,
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
     vm_count = tables.TemplateColumn(
     vm_count = tables.TemplateColumn(
-        template_code=PLATFORM_VM_COUNT,
-        accessor=Accessor('virtual_machines__unrestricted__count'),
-        orderable=False,
+        template_code=VM_COUNT,
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
     actions = ButtonsColumn(Platform, pk_field='slug')
     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.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 from utilities.permissions import get_permission_for_model
 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 (
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
     GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -341,13 +341,14 @@ class RackView(ObjectView):
     def get(self, request, pk):
     def get(self, request, pk):
         rack = get_object_or_404(self.queryset, pk=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,
             rack=rack,
-            position__isnull=True,
-            parent_bay__isnull=True
+            position__isnull=True
         ).prefetch_related('device_type__manufacturer')
         ).prefetch_related('device_type__manufacturer')
 
 
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site)
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site)
+
         if rack.group:
         if rack.group:
             peer_racks = peer_racks.filter(group=rack.group)
             peer_racks = peer_racks.filter(group=rack.group)
         else:
         else:
@@ -474,10 +475,10 @@ class RackReservationBulkDeleteView(BulkDeleteView):
 
 
 class ManufacturerListView(ObjectListView):
 class ManufacturerListView(ObjectListView):
     queryset = Manufacturer.objects.annotate(
     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
     table = tables.ManufacturerTable
 
 
 
 
@@ -919,7 +920,10 @@ class DeviceBayTemplateBulkDeleteView(BulkDeleteView):
 #
 #
 
 
 class DeviceRoleListView(ObjectListView):
 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
     table = tables.DeviceRoleTable
 
 
 
 
@@ -948,7 +952,10 @@ class DeviceRoleBulkDeleteView(BulkDeleteView):
 #
 #
 
 
 class PlatformListView(ObjectListView):
 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
     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.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.template.loader import get_template
 from django.template.loader import get_template
-from django.utils.module_loading import import_string
 
 
 from extras.registry import registry
 from extras.registry import registry
 from utilities.choices import ButtonColorChoices
 from utilities.choices import ButtonColorChoices
 
 
+from extras.plugins.utils import import_object
+
 
 
 # Initialize plugin registry stores
 # Initialize plugin registry stores
 registry['plugin_template_extensions'] = collections.defaultdict(list)
 registry['plugin_template_extensions'] = collections.defaultdict(list)
@@ -60,18 +61,14 @@ class PluginConfig(AppConfig):
     def ready(self):
     def ready(self):
 
 
         # Register template content
         # 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)
             register_template_extensions(template_extensions)
-        except ImportError:
-            pass
 
 
         # Register navigation menu items (if defined)
         # 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)
             register_menu_items(self.verbose_name, menu_items)
-        except ImportError:
-            pass
 
 
     @classmethod
     @classmethod
     def validate(cls, user_config):
     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.conf.urls import include
 from django.contrib.admin.views.decorators import staff_member_required
 from django.contrib.admin.views.decorators import staff_member_required
 from django.urls import path
 from django.urls import path
-from django.utils.module_loading import import_string
+
+from extras.plugins.utils import import_object
 
 
 from . import views
 from . import views
 
 
@@ -24,19 +25,15 @@ for plugin_path in settings.PLUGINS:
     base_url = getattr(app, 'base_url') or app.label
     base_url = getattr(app, 'base_url') or app.label
 
 
     # Check if the plugin specifies any base URLs
     # 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(
         plugin_patterns.append(
             path(f"{base_url}/", include((urlpatterns, app.label)))
             path(f"{base_url}/", include((urlpatterns, app.label)))
         )
         )
-    except ImportError:
-        pass
 
 
     # Check if the plugin specifies any API URLs
     # 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(
         plugin_api_patterns.append(
             path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
             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.conf import settings
 from django.shortcuts import render
 from django.shortcuts import render
 from django.urls.exceptions import NoReverseMatch
 from django.urls.exceptions import NoReverseMatch
-from django.utils.module_loading import import_string
 from django.views.generic import View
 from django.views.generic import View
 from rest_framework import permissions
 from rest_framework import permissions
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 from rest_framework.views import APIView
 from rest_framework.views import APIView
 
 
+from extras.plugins.utils import import_object
+
 
 
 class InstalledPluginsAdminView(View):
 class InstalledPluginsAdminView(View):
     """
     """
@@ -60,9 +61,9 @@ class PluginsAPIRootView(APIView):
 
 
     @staticmethod
     @staticmethod
     def _get_plugin_entry(plugin, app_config, request, format):
     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
             # Plugin does not expose an API
             return None
             return None
 
 
@@ -73,7 +74,7 @@ class PluginsAPIRootView(APIView):
                 format=format
                 format=format
             ))
             ))
         except NoReverseMatch:
         except NoReverseMatch:
-            # The plugin does not include an api-root
+            # The plugin does not include an api-root url
             entry = None
             entry = None
 
 
         return entry
         return entry

+ 3 - 0
netbox/ipam/api/nested_serializers.py

@@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
 
 
 class NestedAggregateSerializer(WritableNestedSerializer):
 class NestedAggregateSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
+    family = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = models.Aggregate
         model = models.Aggregate
@@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
 
 
 class NestedPrefixSerializer(WritableNestedSerializer):
 class NestedPrefixSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
+    family = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = models.Prefix
         model = models.Prefix
@@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
 
 
 class NestedIPAddressSerializer(WritableNestedSerializer):
 class NestedIPAddressSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
+    family = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = models.IPAddress
         model = models.IPAddress

+ 5 - 0
netbox/ipam/api/views.py

@@ -73,6 +73,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.PrefixSerializer
     serializer_class = serializers.PrefixSerializer
     filterset_class = filters.PrefixFilterSet
     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='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
     @swagger_auto_schema(method='post', responses={201: 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'])
     @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 = """
 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 = """
 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 = """
 PREFIX_LINK = """
@@ -283,15 +283,11 @@ class AggregateDetailTable(AggregateTable):
 class RoleTable(BaseTable):
 class RoleTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     prefix_count = tables.TemplateColumn(
     prefix_count = tables.TemplateColumn(
-        accessor=Accessor('prefixes__unrestricted__count'),
         template_code=ROLE_PREFIX_COUNT,
         template_code=ROLE_PREFIX_COUNT,
-        orderable=False,
         verbose_name='Prefixes'
         verbose_name='Prefixes'
     )
     )
     vlan_count = tables.TemplateColumn(
     vlan_count = tables.TemplateColumn(
-        accessor=Accessor('vlans__unrestricted__count'),
         template_code=ROLE_VLAN_COUNT,
         template_code=ROLE_VLAN_COUNT,
-        orderable=False,
         verbose_name='VLANs'
         verbose_name='VLANs'
     )
     )
     actions = ButtonsColumn(Role, pk_field='slug')
     actions = ButtonsColumn(Role, pk_field='slug')
@@ -474,7 +470,7 @@ class InterfaceIPAddressTable(BaseTable):
 
 
 class VLANGroupTable(BaseTable):
 class VLANGroupTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.LinkColumn()
+    name = tables.Column(linkify=True)
     site = tables.LinkColumn(
     site = tables.LinkColumn(
         viewname='dcim:site',
         viewname='dcim:site',
         args=[Accessor('site__slug')]
         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 dcim.models import Device, Interface
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
+from utilities.utils import get_subquery
 from utilities.views import (
 from utilities.views import (
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
     ObjectListView,
     ObjectListView,
@@ -285,7 +286,10 @@ class AggregateBulkDeleteView(BulkDeleteView):
 #
 #
 
 
 class RoleListView(ObjectListView):
 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
     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">
                         <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>
                             <i class="fa fa-share-alt" aria-hidden="true"></i>
                         </a>
                         </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 %}
                     {% else %}
                         {% if perms.dcim.add_cable %}
                         {% if perms.dcim.add_cable %}
                             <div class="pull-right">
                             <div class="pull-right">
@@ -63,10 +68,10 @@
                                         <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
                                         <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
                                     </button>
                                     </button>
                                     <ul class="dropdown-menu dropdown-menu-right">
                                     <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>
                                     </ul>
                                 </span>
                                 </span>
                             </div>
                             </div>

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

@@ -337,7 +337,7 @@
                         <th>Name</th>
                         <th>Name</th>
                         <th>Role</th>
                         <th>Role</th>
                         <th>Type</th>
                         <th>Type</th>
-                        <th>Parent</th>
+                        <th colspan="2">Parent Device</th>
                     </tr>
                     </tr>
                     {% for device in nonracked_devices %}
                     {% for device in nonracked_devices %}
                         <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
                         <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
@@ -346,13 +346,12 @@
                             </td>
                             </td>
                             <td>{{ device.device_role }}</td>
                             <td>{{ device.device_role }}</td>
                             <td>{{ device.device_type.display_name }}</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>
                         </tr>
                     {% endfor %}
                     {% endfor %}
                 </table>
                 </table>

+ 1 - 0
netbox/virtualization/filters.py

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

+ 12 - 6
netbox/virtualization/tables.py

@@ -16,6 +16,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 {{ 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
 # Cluster types
@@ -66,14 +74,12 @@ class ClusterTable(BaseTable):
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
-    device_count = tables.Column(
-        accessor=Accessor('devices__unrestricted__count'),
-        orderable=False,
+    device_count = tables.TemplateColumn(
+        template_code=DEVICE_COUNT,
         verbose_name='Devices'
         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'
         verbose_name='VMs'
     )
     )
     tags = TagColumn(
     tags = TagColumn(

+ 6 - 1
netbox/virtualization/views.py

@@ -9,6 +9,7 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress, Service
 from ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from utilities.utils import get_subquery
 from utilities.views import (
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
     ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -80,7 +81,11 @@ class ClusterGroupBulkDeleteView(BulkDeleteView):
 #
 #
 
 
 class ClusterListView(ObjectListView):
 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
     table = tables.ClusterTable
     filterset = filters.ClusterFilterSet
     filterset = filters.ClusterFilterSet
     filterset_form = forms.ClusterFilterForm
     filterset_form = forms.ClusterFilterForm