Przeglądaj źródła

Merge pull request #4873 from netbox-community/develop

Release v2.8.8
Jeremy Stretch 5 lat temu
rodzic
commit
f1e82a3647

+ 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

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

@@ -1,5 +1,28 @@
 # NetBox v2.8
 # 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)
 ## v2.8.7 (2020-07-02)
 
 
 ### Enhancements
 ### Enhancements

+ 21 - 7
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
@@ -371,15 +372,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
         Execute a NAPALM method on a Device
         Execute a NAPALM method on a Device
         """
         """
         device = get_object_or_404(Device, pk=pk)
         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:
         if device.platform is None:
             raise ServiceUnavailable("No platform is configured for this device.")
             raise ServiceUnavailable("No platform is configured for this device.")
         if not device.platform.napalm_driver:
         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
                 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
@@ -399,10 +414,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
         if not request.user.has_perm('dcim.napalm_read'):
         if not request.user.has_perm('dcim.napalm_read'):
             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()
@@ -422,8 +435,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,
@@ -432,7 +446,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'),
     )
     )
 
 
@@ -275,6 +279,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'
@@ -290,6 +299,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
@@ -351,6 +364,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'),
@@ -367,6 +385,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'),
         )),
         )),
@@ -436,6 +458,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'
@@ -451,6 +478,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
@@ -513,6 +544,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'),
@@ -529,6 +565,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

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

+ 10 - 24
netbox/dcim/tables.py

@@ -103,20 +103,12 @@ DEVICEROLE_ACTIONS = """
 {% endif %}
 {% 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 = """
 PLATFORM_ACTIONS = """
@@ -278,6 +270,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 = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -704,21 +697,18 @@ class DeviceBayTemplateTable(BaseTable):
 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.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.count'),
-        orderable=False,
+        template_code=VM_COUNT,
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
     color = tables.TemplateColumn(
     color = tables.TemplateColumn(
         template_code=COLOR_LABEL,
         template_code=COLOR_LABEL,
         verbose_name='Label'
         verbose_name='Label'
     )
     )
+    vm_role = BooleanColumn()
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=DEVICEROLE_ACTIONS,
         template_code=DEVICEROLE_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -738,15 +728,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.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.count'),
-        orderable=False,
+        template_code=VM_COUNT,
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
     actions = tables.TemplateColumn(
     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 ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
-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, ComponentCreateView, GetReturnURLMixin,
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
     ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
     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)
         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(
         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')
+
         if rack.group:
         if rack.group:
             peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
             peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
         else:
         else:
@@ -557,9 +558,9 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
 class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_manufacturer'
     permission_required = 'dcim.view_manufacturer'
     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),
+        devicetype_count=get_subquery(DeviceType, 'manufacturer'),
+        inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
+        platform_count=get_subquery(Platform, 'manufacturer')
     )
     )
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
 
 
@@ -1020,7 +1021,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
 class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_devicerole'
     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
     table = tables.DeviceRoleTable
 
 
 
 
@@ -1055,7 +1059,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class PlatformListView(PermissionRequiredMixin, ObjectListView):
 class PlatformListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_platform'
     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
     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.reports import get_report, get_reports
 from extras.scripts import get_script, get_scripts, run_script
 from extras.scripts import get_script, get_scripts, run_script
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
+from utilities.metadata import ContentTypeMetadata
 from . import serializers
 from . import serializers
 
 
 
 
@@ -88,6 +89,7 @@ class CustomFieldModelViewSet(ModelViewSet):
 #
 #
 
 
 class GraphViewSet(ModelViewSet):
 class GraphViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
     queryset = Graph.objects.all()
     queryset = Graph.objects.all()
     serializer_class = serializers.GraphSerializer
     serializer_class = serializers.GraphSerializer
     filterset_class = filters.GraphFilterSet
     filterset_class = filters.GraphFilterSet
@@ -98,6 +100,7 @@ class GraphViewSet(ModelViewSet):
 #
 #
 
 
 class ExportTemplateViewSet(ModelViewSet):
 class ExportTemplateViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
     serializer_class = serializers.ExportTemplateSerializer
     filterset_class = filters.ExportTemplateFilterSet
     filterset_class = filters.ExportTemplateFilterSet
@@ -120,6 +123,7 @@ class TagViewSet(ModelViewSet):
 #
 #
 
 
 class ImageAttachmentViewSet(ModelViewSet):
 class ImageAttachmentViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
     serializer_class = serializers.ImageAttachmentSerializer
 
 
@@ -271,6 +275,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     """
     """
     Retrieve a list of recent changes.
     Retrieve a list of recent changes.
     """
     """
+    metadata_class = ContentTypeMetadata
     queryset = ObjectChange.objects.prefetch_related('user')
     queryset = ObjectChange.objects.prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filters.ObjectChangeFilterSet
     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.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

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

+ 6 - 1
netbox/ipam/forms.py

@@ -1068,7 +1068,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
     )
     )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            filter_for={
+                'group': 'site_id'
+            }
+        )
     )
     )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),

+ 3 - 7
netbox/ipam/tables.py

@@ -40,11 +40,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>
 """
 """
 
 
 ROLE_ACTIONS = """
 ROLE_ACTIONS = """
@@ -319,15 +319,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.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.count'),
         template_code=ROLE_VLAN_COUNT,
         template_code=ROLE_VLAN_COUNT,
-        orderable=False,
         verbose_name='VLANs'
         verbose_name='VLANs'
     )
     )
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -524,7 +520,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

@@ -9,6 +9,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, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
@@ -407,7 +408,10 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class RoleListView(PermissionRequiredMixin, ObjectListView):
 class RoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_role'
     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
     table = tables.RoleTable
 
 
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.8.7'
+VERSION = '2.8.8'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 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">
                         <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>

+ 7 - 14
netbox/utilities/forms.py

@@ -594,21 +594,20 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     filter = django_filters.ModelChoiceFilter
     widget = APISelect
     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):
     def get_bound_field(self, form, field_name):
         bound_field = BoundField(form, self, 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
         # 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.
         # will be populated on-demand via the APISelect widget.
         data = bound_field.value()
         data = bound_field.value()
         if data:
         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:
         else:
             self.queryset = self.queryset.none()
             self.queryset = self.queryset.none()
 
 
@@ -638,12 +637,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
     filter = django_filters.ModelMultipleChoiceFilter
     filter = django_filters.ModelMultipleChoiceFilter
     widget = APISelectMultiple
     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):
 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 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):
 class DictToFilterParamsTest(TestCase):
     """
     """
     Validate the operation of dict_to_filter_params().
     Validate the operation of dict_to_filter_params().
     """
     """
-    def setUp(self):
-        return
-
     def test_dict_to_filter_params(self):
     def test_dict_to_filter_params(self):
 
 
         input = {
         input = {
@@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase):
         self.assertNotEqual(dict_to_filter_params(input), output)
         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):
 class DeepMergeTest(TestCase):
     """
     """
     Validate the behavior of the deepmerge() utility.
     Validate the behavior of the deepmerge() utility.
     """
     """
-    def setUp(self):
-        return
-
     def test_deepmerge(self):
     def test_deepmerge(self):
 
 
         dict1 = {
         dict1 = {

+ 18 - 0
netbox/utilities/utils.py

@@ -150,6 +150,24 @@ def dict_to_filter_params(d, prefix=''):
     return params
     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):
 def deepmerge(original, new):
     """
     """
     Deep merge two dictionaries (new into original) and return a new dict
     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 extras.querysets import CustomFieldQueryset
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
 from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
 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 .error_handlers import handle_protectederror
 from .forms import ConfirmationForm, ImportForm
 from .forms import ConfirmationForm, ImportForm
 from .paginator import EnhancedPaginator, get_paginate_count
 from .paginator import EnhancedPaginator, get_paginate_count
@@ -250,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View):
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
         # Parse initial data manually to avoid setting field values as lists
         # 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)
         form = self.model_form(instance=self.obj, initial=initial_data)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {

+ 1 - 0
netbox/virtualization/filters.py

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

+ 12 - 6
netbox/virtualization/tables.py

@@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 {{ 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
 # Cluster types
@@ -94,14 +102,12 @@ class ClusterTable(BaseTable):
         viewname='dcim:site',
         viewname='dcim:site',
         args=[Accessor('site.slug')]
         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'
         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'
         verbose_name='VMs'
     )
     )
     tags = TagColumn(
     tags = TagColumn(

+ 5 - 1
netbox/virtualization/views.py

@@ -10,6 +10,7 @@ from dcim.models import Device, Interface
 from dcim.tables import DeviceTable
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import Service
 from ipam.models import Service
+from utilities.utils import get_subquery
 from utilities.views import (
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
     ObjectEditView, ObjectListView,
     ObjectEditView, ObjectListView,
@@ -94,7 +95,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class ClusterListView(PermissionRequiredMixin, ObjectListView):
 class ClusterListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'virtualization.view_cluster'
     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
     table = tables.ClusterTable
     filterset = filters.ClusterFilterSet
     filterset = filters.ClusterFilterSet
     filterset_form = forms.ClusterFilterForm
     filterset_form = forms.ClusterFilterForm