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

Merge pull request #5472 from netbox-community/develop

Release v2.10.1
Jeremy Stretch 5 лет назад
Родитель
Сommit
2c1a60b965

+ 16 - 0
docs/release-notes/version-2.10.md

@@ -1,5 +1,21 @@
 # NetBox v2.10
 
+## v2.10.1 (2020-12-15)
+
+### Bug Fixes
+
+* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces
+* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value
+* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable
+* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception
+* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception
+* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception
+* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device
+* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields
+* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint
+
+---
+
 ## v2.10.0 (2020-12-14)
 
 **NOTE:** This release completely removes support for embedded graphs.

+ 3 - 2
netbox/circuits/api/views.py

@@ -1,4 +1,5 @@
 from django.db.models import Prefetch
+from django.db.models.functions import Coalesce
 from rest_framework.routers import APIRootView
 
 from circuits import filters
@@ -24,7 +25,7 @@ class CircuitsRootView(APIRootView):
 
 class ProviderViewSet(CustomFieldModelViewSet):
     queryset = Provider.objects.prefetch_related('tags').annotate(
-        circuit_count=get_subquery(Circuit, 'provider')
+        circuit_count=Coalesce(get_subquery(Circuit, 'provider'), 0)
     )
     serializer_class = serializers.ProviderSerializer
     filterset_class = filters.ProviderFilterSet
@@ -36,7 +37,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
 
 class CircuitTypeViewSet(ModelViewSet):
     queryset = CircuitType.objects.annotate(
-        circuit_count=get_subquery(Circuit, 'type')
+        circuit_count=Coalesce(get_subquery(Circuit, 'type'), 0)
     )
     serializer_class = serializers.CircuitTypeSerializer
     filterset_class = filters.CircuitTypeFilterSet

+ 2 - 2
netbox/circuits/views.py

@@ -139,7 +139,7 @@ class CircuitView(generic.ObjectView):
         ).filter(
             circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
         ).first()
-        if termination_a and termination_a.connected_endpoint:
+        if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
             termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
 
         # Z-side termination
@@ -148,7 +148,7 @@ class CircuitView(generic.ObjectView):
         ).filter(
             circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
         ).first()
-        if termination_z and termination_z.connected_endpoint:
+        if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
             termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
 
         return {

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

@@ -3,6 +3,7 @@ from collections import OrderedDict
 
 from django.conf import settings
 from django.db.models import F
+from django.db.models.functions import Coalesce
 from django.http import HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
@@ -119,12 +120,12 @@ class SiteViewSet(CustomFieldModelViewSet):
     queryset = Site.objects.prefetch_related(
         'region', 'tenant', 'tags'
     ).annotate(
-        device_count=get_subquery(Device, 'site'),
-        rack_count=get_subquery(Rack, 'site'),
-        prefix_count=get_subquery(Prefix, 'site'),
-        vlan_count=get_subquery(VLAN, 'site'),
-        circuit_count=get_subquery(Circuit, 'terminations__site'),
-        virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
+        device_count=Coalesce(get_subquery(Device, 'site'), 0),
+        rack_count=Coalesce(get_subquery(Rack, 'site'), 0),
+        prefix_count=Coalesce(get_subquery(Prefix, 'site'), 0),
+        vlan_count=Coalesce(get_subquery(VLAN, 'site'), 0),
+        circuit_count=Coalesce(get_subquery(Circuit, 'terminations__site'), 0),
+        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster__site'), 0),
     )
     serializer_class = serializers.SiteSerializer
     filterset_class = filters.SiteFilterSet
@@ -152,7 +153,7 @@ class RackGroupViewSet(ModelViewSet):
 
 class RackRoleViewSet(ModelViewSet):
     queryset = RackRole.objects.annotate(
-        rack_count=get_subquery(Rack, 'role')
+        rack_count=Coalesce(get_subquery(Rack, 'role'), 0)
     )
     serializer_class = serializers.RackRoleSerializer
     filterset_class = filters.RackRoleFilterSet
@@ -166,8 +167,8 @@ class RackViewSet(CustomFieldModelViewSet):
     queryset = Rack.objects.prefetch_related(
         'site', 'group__site', 'role', 'tenant', 'tags'
     ).annotate(
-        device_count=get_subquery(Device, 'rack'),
-        powerfeed_count=get_subquery(PowerFeed, 'rack')
+        device_count=Coalesce(get_subquery(Device, 'rack'), 0),
+        powerfeed_count=Coalesce(get_subquery(PowerFeed, 'rack'), 0)
     )
     serializer_class = serializers.RackSerializer
     filterset_class = filters.RackFilterSet
@@ -240,9 +241,9 @@ class RackReservationViewSet(ModelViewSet):
 
 class ManufacturerViewSet(ModelViewSet):
     queryset = Manufacturer.objects.annotate(
-        devicetype_count=get_subquery(DeviceType, 'manufacturer'),
-        inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
-        platform_count=get_subquery(Platform, 'manufacturer')
+        devicetype_count=Coalesce(get_subquery(DeviceType, 'manufacturer'), 0),
+        inventoryitem_count=Coalesce(get_subquery(InventoryItem, 'manufacturer'), 0),
+        platform_count=Coalesce(get_subquery(Platform, 'manufacturer'), 0)
     )
     serializer_class = serializers.ManufacturerSerializer
     filterset_class = filters.ManufacturerFilterSet
@@ -254,7 +255,7 @@ class ManufacturerViewSet(ModelViewSet):
 
 class DeviceTypeViewSet(CustomFieldModelViewSet):
     queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
-        device_count=get_subquery(Device, 'device_type')
+        device_count=Coalesce(get_subquery(Device, 'device_type'), 0)
     )
     serializer_class = serializers.DeviceTypeSerializer
     filterset_class = filters.DeviceTypeFilterSet
@@ -318,8 +319,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
 
 class DeviceRoleViewSet(ModelViewSet):
     queryset = DeviceRole.objects.annotate(
-        device_count=get_subquery(Device, 'device_role'),
-        virtualmachine_count=get_subquery(VirtualMachine, 'role')
+        device_count=Coalesce(get_subquery(Device, 'device_role'), 0),
+        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'role'), 0)
     )
     serializer_class = serializers.DeviceRoleSerializer
     filterset_class = filters.DeviceRoleFilterSet
@@ -331,8 +332,8 @@ class DeviceRoleViewSet(ModelViewSet):
 
 class PlatformViewSet(ModelViewSet):
     queryset = Platform.objects.annotate(
-        device_count=get_subquery(Device, 'platform'),
-        virtualmachine_count=get_subquery(VirtualMachine, 'platform')
+        device_count=Coalesce(get_subquery(Device, 'platform'), 0),
+        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'platform'), 0)
     )
     serializer_class = serializers.PlatformSerializer
     filterset_class = filters.PlatformFilterSet
@@ -596,7 +597,7 @@ class CableViewSet(ModelViewSet):
 
 class VirtualChassisViewSet(ModelViewSet):
     queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
-        member_count=get_subquery(Device, 'virtual_chassis')
+        member_count=Coalesce(get_subquery(Device, 'virtual_chassis'), 0)
     )
     serializer_class = serializers.VirtualChassisSerializer
     filterset_class = filters.VirtualChassisFilterSet
@@ -610,7 +611,7 @@ class PowerPanelViewSet(ModelViewSet):
     queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
     ).annotate(
-        powerfeed_count=get_subquery(PowerFeed, 'power_panel')
+        powerfeed_count=Coalesce(get_subquery(PowerFeed, 'power_panel'), 0)
     )
     serializer_class = serializers.PowerPanelSerializer
     filterset_class = filters.PowerPanelFilterSet

+ 10 - 1
netbox/dcim/forms.py

@@ -2839,7 +2839,7 @@ class InterfaceBulkCreateForm(
 
 class InterfaceBulkEditForm(
     form_from_model(Interface, [
-        'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode'
+        'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
     ]),
     BootstrapMixin,
     AddRemoveTagsForm,
@@ -2855,6 +2855,15 @@ class InterfaceBulkEditForm(
         disabled=True,
         widget=forms.HiddenInput()
     )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    mgmt_only = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label='Management only'
+    )
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,

+ 2 - 1
netbox/dcim/models/cables.py

@@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
         return instance
 
     def __str__(self):
-        return self.label or '#{}'.format(self._pk)
+        pk = self.pk or self._pk
+        return self.label or f'#{pk}'
 
     def get_absolute_url(self):
         return reverse('dcim:cable', args=[self.pk])

+ 8 - 0
netbox/dcim/tests/test_views.py

@@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'comments': 'New comments',
         }
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_list_rack_elevations(self):
+        """
+        Test viewing the list of rack elevations.
+        """
+        response = self.client.get(reverse('dcim:rack_elevation_list'))
+        self.assertHttpStatus(response, 200)
+
 
 class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Manufacturer

+ 2 - 1
netbox/extras/api/views.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
+from django.db.models.functions import Coalesce
 from django.http import Http404
 from django_rq.queues import get_connection
 from rest_framework import status
@@ -102,7 +103,7 @@ class ExportTemplateViewSet(ModelViewSet):
 
 class TagViewSet(ModelViewSet):
     queryset = Tag.objects.annotate(
-        tagged_items=get_subquery(TaggedItem, 'tag')
+        tagged_items=Coalesce(get_subquery(TaggedItem, 'tag'), 0)
     )
     serializer_class = serializers.TagSerializer
     filterset_class = filters.TagFilterSet

+ 2 - 2
netbox/extras/forms.py

@@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm):
             # Annotate the field in the list of CustomField form fields
             self.custom_fields.append(field_name)
 
-    def save(self, commit=True):
+    def clean(self):
 
         # Save custom field data on instance
         for cf_name in self.custom_fields:
             self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
 
-        return super().save(commit)
+        return super().clean()
 
 
 class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):

+ 6 - 5
netbox/extras/models/customfields.py

@@ -1,6 +1,6 @@
 import re
 from collections import OrderedDict
-from datetime import datetime
+from datetime import datetime, date
 
 from django import forms
 from django.contrib.contenttypes.models import ContentType
@@ -317,10 +317,11 @@ class CustomField(models.Model):
 
             # Validate date
             if self.type == CustomFieldTypeChoices.TYPE_DATE:
-                try:
-                    datetime.strptime(value, '%Y-%m-%d')
-                except ValueError:
-                    raise ValidationError("Date values must be in the format YYYY-MM-DD.")
+                if type(value) is not date:
+                    try:
+                        datetime.strptime(value, '%Y-%m-%d')
+                    except ValueError:
+                        raise ValidationError("Date values must be in the format YYYY-MM-DD.")
 
             # Validate selected choice
             if self.type == CustomFieldTypeChoices.TYPE_SELECT:

+ 8 - 7
netbox/ipam/api/views.py

@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.db.models.functions import Coalesce
 from django.shortcuts import get_object_or_404
 from django_pglocks import advisory_lock
 from drf_yasg.utils import swagger_auto_schema
@@ -32,8 +33,8 @@ class VRFViewSet(CustomFieldModelViewSet):
     queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
         'import_targets', 'export_targets', 'tags'
     ).annotate(
-        ipaddress_count=get_subquery(IPAddress, 'vrf'),
-        prefix_count=get_subquery(Prefix, 'vrf')
+        ipaddress_count=Coalesce(get_subquery(IPAddress, 'vrf'), 0),
+        prefix_count=Coalesce(get_subquery(Prefix, 'vrf'), 0)
     )
     serializer_class = serializers.VRFSerializer
     filterset_class = filters.VRFFilterSet
@@ -55,7 +56,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
 
 class RIRViewSet(ModelViewSet):
     queryset = RIR.objects.annotate(
-        aggregate_count=get_subquery(Aggregate, 'rir')
+        aggregate_count=Coalesce(get_subquery(Aggregate, 'rir'), 0)
     )
     serializer_class = serializers.RIRSerializer
     filterset_class = filters.RIRFilterSet
@@ -77,8 +78,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
 
 class RoleViewSet(ModelViewSet):
     queryset = Role.objects.annotate(
-        prefix_count=get_subquery(Prefix, 'role'),
-        vlan_count=get_subquery(VLAN, 'role')
+        prefix_count=Coalesce(get_subquery(Prefix, 'role'), 0),
+        vlan_count=Coalesce(get_subquery(VLAN, 'role'), 0)
     )
     serializer_class = serializers.RoleSerializer
     filterset_class = filters.RoleFilterSet
@@ -272,7 +273,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 
 class VLANGroupViewSet(ModelViewSet):
     queryset = VLANGroup.objects.prefetch_related('site').annotate(
-        vlan_count=get_subquery(VLAN, 'group')
+        vlan_count=Coalesce(get_subquery(VLAN, 'group'), 0)
     )
     serializer_class = serializers.VLANGroupSerializer
     filterset_class = filters.VLANGroupFilterSet
@@ -286,7 +287,7 @@ class VLANViewSet(CustomFieldModelViewSet):
     queryset = VLAN.objects.prefetch_related(
         'site', 'group', 'tenant', 'role', 'tags'
     ).annotate(
-        prefix_count=get_subquery(Prefix, 'vlan')
+        prefix_count=Coalesce(get_subquery(Prefix, 'vlan'), 0)
     )
     serializer_class = serializers.VLANSerializer
     filterset_class = filters.VLANFilterSet

+ 36 - 1
netbox/netbox/api/metadata.py

@@ -1,10 +1,45 @@
+from django.core.exceptions import PermissionDenied
+from django.http import Http404
 from django.utils.encoding import force_str
+from rest_framework import exceptions
 from rest_framework.metadata import SimpleMetadata
+from rest_framework.request import clone_request
 
 from netbox.api import ContentTypeField
 
 
-class ContentTypeMetadata(SimpleMetadata):
+class BulkOperationMetadata(SimpleMetadata):
+
+    def determine_actions(self, request, view):
+        """
+        Replace the stock determine_actions() method to assess object permissions only
+        when viewing a specific object. This is necessary to support OPTIONS requests
+        with bulk update in place (see #5470).
+        """
+        actions = {}
+        for method in {'PUT', 'POST'} & set(view.allowed_methods):
+            view.request = clone_request(request, method)
+            try:
+                # Test global permissions
+                if hasattr(view, 'check_permissions'):
+                    view.check_permissions(view.request)
+                # Test object permissions (if viewing a specific object)
+                if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'):
+                    view.get_object()
+            except (exceptions.APIException, PermissionDenied, Http404):
+                pass
+            else:
+                # If user has appropriate permissions for the view, include
+                # appropriate metadata about the fields that should be supplied.
+                serializer = view.get_serializer()
+                actions[method] = self.get_serializer_info(serializer)
+            finally:
+                view.request = request
+
+        return actions
+
+
+class ContentTypeMetadata(BulkOperationMetadata):
 
     def get_field_info(self, field):
         field_info = super().get_field_info(field)

+ 2 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.10.0'
+VERSION = '2.10.1'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -467,6 +467,7 @@ REST_FRAMEWORK = {
     'DEFAULT_FILTER_BACKENDS': (
         'django_filters.rest_framework.DjangoFilterBackend',
     ),
+    'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
     'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
     'DEFAULT_PERMISSION_CLASSES': (
         'netbox.api.authentication.TokenPermissions',

+ 2 - 1
netbox/secrets/api/views.py

@@ -1,6 +1,7 @@
 import base64
 
 from Crypto.PublicKey import RSA
+from django.db.models.functions import Coalesce
 from django.http import HttpResponseBadRequest
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
@@ -35,7 +36,7 @@ class SecretsRootView(APIRootView):
 
 class SecretRoleViewSet(ModelViewSet):
     queryset = SecretRole.objects.annotate(
-        secret_count=get_subquery(Secret, 'role')
+        secret_count=Coalesce(get_subquery(Secret, 'role'), 0)
     )
     serializer_class = serializers.SecretRoleSerializer
     filterset_class = filters.SecretRoleFilterSet

+ 1 - 1
netbox/templates/dcim/inc/cable_toggle_buttons.html

@@ -10,7 +10,7 @@
     {% endif %}
 {% endif %}
 {% if perms.dcim.delete_cable %}
-    <a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ device.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
+    <a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ object.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
             <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
     </a>
 {% endif %}

+ 4 - 4
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -9,18 +9,18 @@
             {% include 'responsive_table.html' %}
             <div class="panel-footer noprint">
                 {% if table.rows %}
-                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
+                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
                         <span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
                     </button>
-                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
+                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
                         <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
                     </button>
-                    <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
+                    <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-danger">
                         <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
                     </button>
                 {% endif %}
                 <div class="pull-right">
-                    <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs">
+                    <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs">
                         <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
                         Add {{ title }}
                     </a>

+ 1 - 1
netbox/templates/dcim/rack_elevation_list.html

@@ -35,7 +35,7 @@
                                 <br /><small class="text-muted">{{ rack.facility_id }}</small>
                             {% endif %}
                         </div>
-                        {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
+                        {% include 'dcim/inc/rack_elevation.html' with object=rack face=rack_face %}
                         <div class="clearfix"></div>
                         <div class="text-center">
                             <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>

+ 8 - 7
netbox/tenancy/api/views.py

@@ -1,3 +1,4 @@
+from django.db.models.functions import Coalesce
 from rest_framework.routers import APIRootView
 
 from circuits.models import Circuit
@@ -46,13 +47,13 @@ class TenantViewSet(CustomFieldModelViewSet):
     ).annotate(
         circuit_count=get_subquery(Circuit, 'tenant'),
         device_count=get_subquery(Device, 'tenant'),
-        ipaddress_count=get_subquery(IPAddress, 'tenant'),
-        prefix_count=get_subquery(Prefix, 'tenant'),
-        rack_count=get_subquery(Rack, 'tenant'),
-        site_count=get_subquery(Site, 'tenant'),
-        virtualmachine_count=get_subquery(VirtualMachine, 'tenant'),
-        vlan_count=get_subquery(VLAN, 'tenant'),
-        vrf_count=get_subquery(VRF, 'tenant')
+        ipaddress_count=Coalesce(get_subquery(IPAddress, 'tenant'), 0),
+        prefix_count=Coalesce(get_subquery(Prefix, 'tenant'), 0),
+        rack_count=Coalesce(get_subquery(Rack, 'tenant'), 0),
+        site_count=Coalesce(get_subquery(Site, 'tenant'), 0),
+        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'tenant'), 0),
+        vlan_count=Coalesce(get_subquery(VLAN, 'tenant'), 0),
+        vrf_count=Coalesce(get_subquery(VRF, 'tenant'), 0)
     )
     serializer_class = serializers.TenantSerializer
     filterset_class = filters.TenantFilterSet

+ 17 - 0
netbox/utilities/testing/api.py

@@ -109,6 +109,15 @@ class APIViewTestCases:
             url = self._get_detail_url(instance2)
             self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
 
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+        def test_options_object(self):
+            """
+            Make an OPTIONS request for a single object.
+            """
+            url = self._get_detail_url(self._get_queryset().first())
+            response = self.client.options(url, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+
     class ListObjectsViewTestCase(APITestCase):
         brief_fields = []
 
@@ -174,6 +183,14 @@ class APIViewTestCases:
             self.assertHttpStatus(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data['results']), 2)
 
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+        def test_options_objects(self):
+            """
+            Make an OPTIONS request for a list endpoint.
+            """
+            response = self.client.options(self._get_list_url(), **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+
     class CreateObjectViewTestCase(APITestCase):
         create_data = []
         validation_excluded_fields = []

+ 5 - 4
netbox/virtualization/api/views.py

@@ -1,3 +1,4 @@
+from django.db.models.functions import Coalesce
 from rest_framework.routers import APIRootView
 
 from dcim.models import Device
@@ -22,7 +23,7 @@ class VirtualizationRootView(APIRootView):
 
 class ClusterTypeViewSet(ModelViewSet):
     queryset = ClusterType.objects.annotate(
-        cluster_count=get_subquery(Cluster, 'type')
+        cluster_count=Coalesce(get_subquery(Cluster, 'type'), 0)
     )
     serializer_class = serializers.ClusterTypeSerializer
     filterset_class = filters.ClusterTypeFilterSet
@@ -30,7 +31,7 @@ class ClusterTypeViewSet(ModelViewSet):
 
 class ClusterGroupViewSet(ModelViewSet):
     queryset = ClusterGroup.objects.annotate(
-        cluster_count=get_subquery(Cluster, 'group')
+        cluster_count=Coalesce(get_subquery(Cluster, 'group'), 0)
     )
     serializer_class = serializers.ClusterGroupSerializer
     filterset_class = filters.ClusterGroupFilterSet
@@ -40,8 +41,8 @@ class ClusterViewSet(CustomFieldModelViewSet):
     queryset = Cluster.objects.prefetch_related(
         'type', 'group', 'tenant', 'site', 'tags'
     ).annotate(
-        device_count=get_subquery(Device, 'cluster'),
-        virtualmachine_count=get_subquery(VirtualMachine, 'cluster')
+        device_count=Coalesce(get_subquery(Device, 'cluster'), 0),
+        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster'), 0)
     )
     serializer_class = serializers.ClusterSerializer
     filterset_class = filters.ClusterFilterSet