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

Closes #15131: Dynamic queryset annotations for REST API endpoints (#15152)

* Introduce RelatedObjectCountField

* Introduce get_annotations_for_serializer() and enable dynamic annotations

* Add RelatedObjectCountFields to serializers; remove static annotations from querysets

* Remove annotations cleanup logic from BriefModeMixin

* Annotate type for RelatedObjectCountField

* Remove redundant field on TagSerializer

* Add missing reverse relationship for power feeds to rack

* Refactor RelatedObjectCountField to take a single relationship name
Jeremy Stretch 2 лет назад
Родитель
Сommit
7abb2b2ab5

+ 4 - 4
netbox/circuits/api/nested_serializers.py

@@ -1,8 +1,8 @@
-from drf_spectacular.utils import extend_schema_field, extend_schema_serializer
-from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from circuits.models import *
+from netbox.api.fields import RelatedObjectCountField
 from netbox.api.serializers import WritableNestedSerializer
 
 __all__ = [
@@ -36,7 +36,7 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer):
 )
 class NestedProviderSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
-    circuit_count = serializers.IntegerField(read_only=True)
+    circuit_count = RelatedObjectCountField('circuits')
 
     class Meta:
         model = Provider
@@ -64,7 +64,7 @@ class NestedProviderAccountSerializer(WritableNestedSerializer):
 )
 class NestedCircuitTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
-    circuit_count = serializers.IntegerField(read_only=True)
+    circuit_count = RelatedObjectCountField('circuits')
 
     class Meta:
         model = CircuitType

+ 7 - 5
netbox/circuits/api/serializers.py

@@ -6,7 +6,7 @@ from dcim.api.nested_serializers import NestedSiteSerializer
 from dcim.api.serializers import CabledObjectSerializer
 from ipam.models import ASN
 from ipam.api.nested_serializers import NestedASNSerializer
-from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
@@ -32,7 +32,7 @@ class ProviderSerializer(NetBoxModelSerializer):
     )
 
     # Related object counts
-    circuit_count = serializers.IntegerField(read_only=True)
+    circuit_count = RelatedObjectCountField('circuits')
 
     class Meta:
         model = Provider
@@ -80,13 +80,15 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
 
 class CircuitTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
-    circuit_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    circuit_count = RelatedObjectCountField('circuits')
 
     class Meta:
         model = CircuitType
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
-            'circuit_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'circuit_count',
         ]
 
 

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

@@ -4,7 +4,6 @@ from circuits import filtersets
 from circuits.models import *
 from dcim.api.views import PassThroughPortMixin
 from netbox.api.viewsets import NetBoxModelViewSet
-from utilities.utils import count_related
 from . import serializers
 
 
@@ -21,9 +20,7 @@ class CircuitsRootView(APIRootView):
 #
 
 class ProviderViewSet(NetBoxModelViewSet):
-    queryset = Provider.objects.annotate(
-        circuit_count=count_related(Circuit, 'provider')
-    )
+    queryset = Provider.objects.all()
     serializer_class = serializers.ProviderSerializer
     filterset_class = filtersets.ProviderFilterSet
 
@@ -33,9 +30,7 @@ class ProviderViewSet(NetBoxModelViewSet):
 #
 
 class CircuitTypeViewSet(NetBoxModelViewSet):
-    queryset = CircuitType.objects.annotate(
-        circuit_count=count_related(Circuit, 'type')
-    )
+    queryset = CircuitType.objects.all()
     serializer_class = serializers.CircuitTypeSerializer
     filterset_class = filtersets.CircuitTypeFilterSet
 

+ 2 - 4
netbox/core/api/serializers.py

@@ -2,7 +2,7 @@ from rest_framework import serializers
 
 from core.choices import *
 from core.models import *
-from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
 from netbox.utils import get_data_backend_choices
 from users.api.nested_serializers import NestedUserSerializer
@@ -28,9 +28,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
     )
 
     # Related object counts
-    file_count = serializers.IntegerField(
-        read_only=True
-    )
+    file_count = RelatedObjectCountField('datafiles')
 
     class Meta:
         model = DataSource

+ 1 - 4
netbox/core/api/views.py

@@ -9,7 +9,6 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
 from core import filtersets
 from core.models import *
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
-from utilities.utils import count_related
 from . import serializers
 
 
@@ -22,9 +21,7 @@ class CoreRootView(APIRootView):
 
 
 class DataSourceViewSet(NetBoxModelViewSet):
-    queryset = DataSource.objects.annotate(
-        file_count=count_related(DataFile, 'source')
-    )
+    queryset = DataSource.objects.all()
     serializer_class = serializers.DataSourceSerializer
     filterset_class = filtersets.DataSourceFilterSet
 

+ 12 - 12
netbox/dcim/api/nested_serializers.py

@@ -2,7 +2,8 @@ from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from dcim import models
-from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import WritableNestedSerializer
 
 __all__ = [
     'ComponentNestedModuleSerializer',
@@ -110,7 +111,7 @@ class NestedLocationSerializer(WritableNestedSerializer):
 )
 class NestedRackRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
-    rack_count = serializers.IntegerField(read_only=True)
+    rack_count = RelatedObjectCountField('racks')
 
     class Meta:
         model = models.RackRole
@@ -122,7 +123,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
 )
 class NestedRackSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
-    device_count = serializers.IntegerField(read_only=True)
+    device_count = RelatedObjectCountField('devices')
 
     class Meta:
         model = models.Rack
@@ -150,7 +151,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
 )
 class NestedManufacturerSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
-    devicetype_count = serializers.IntegerField(read_only=True)
+    devicetype_count = RelatedObjectCountField('device_types')
 
     class Meta:
         model = models.Manufacturer
@@ -163,7 +164,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
 class NestedDeviceTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer(read_only=True)
-    device_count = serializers.IntegerField(read_only=True)
+    device_count = RelatedObjectCountField('instances')
 
     class Meta:
         model = models.DeviceType
@@ -173,7 +174,6 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
 class NestedModuleTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
     manufacturer = NestedManufacturerSerializer(read_only=True)
-    # module_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = models.ModuleType
@@ -274,8 +274,8 @@ class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
 )
 class NestedDeviceRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
-    device_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = models.DeviceRole
@@ -287,8 +287,8 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
 )
 class NestedPlatformSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
-    device_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = models.Platform
@@ -445,7 +445,7 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
 )
 class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
-    inventoryitem_count = serializers.IntegerField(read_only=True)
+    inventoryitem_count = RelatedObjectCountField('inventory_items')
 
     class Meta:
         model = models.InventoryItemRole
@@ -490,7 +490,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
 )
 class NestedPowerPanelSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
-    powerfeed_count = serializers.IntegerField(read_only=True)
+    powerfeed_count = RelatedObjectCountField('powerfeeds')
 
     class Meta:
         model = models.PowerPanel

+ 37 - 21
netbox/dcim/api/serializers.py

@@ -15,7 +15,7 @@ from ipam.api.nested_serializers import (
     NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
 )
 from ipam.models import ASN, VLAN
-from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import (
     GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
     WritableNestedSerializer,
@@ -144,12 +144,12 @@ class SiteSerializer(NetBoxModelSerializer):
     )
 
     # Related object counts
-    circuit_count = serializers.IntegerField(read_only=True)
-    device_count = serializers.IntegerField(read_only=True)
-    prefix_count = serializers.IntegerField(read_only=True)
-    rack_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
-    vlan_count = serializers.IntegerField(read_only=True)
+    circuit_count = RelatedObjectCountField('circuit_terminations')
+    device_count = RelatedObjectCountField('devices')
+    prefix_count = RelatedObjectCountField('prefixes')
+    rack_count = RelatedObjectCountField('racks')
+    vlan_count = RelatedObjectCountField('vlans')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = Site
@@ -184,7 +184,9 @@ class LocationSerializer(NestedGroupModelSerializer):
 
 class RackRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
-    rack_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    rack_count = RelatedObjectCountField('racks')
 
     class Meta:
         model = RackRole
@@ -207,8 +209,10 @@ class RackSerializer(NetBoxModelSerializer):
     width = ChoiceField(choices=RackWidthChoices, required=False)
     outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
-    device_count = serializers.IntegerField(read_only=True)
-    powerfeed_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    powerfeed_count = RelatedObjectCountField('powerfeeds')
 
     class Meta:
         model = Rack
@@ -299,9 +303,11 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
 
 class ManufacturerSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
-    devicetype_count = serializers.IntegerField(read_only=True)
-    inventoryitem_count = serializers.IntegerField(read_only=True)
-    platform_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    devicetype_count = RelatedObjectCountField('device_types')
+    inventoryitem_count = RelatedObjectCountField('inventory_items')
+    platform_count = RelatedObjectCountField('platforms')
 
     class Meta:
         model = Manufacturer
@@ -325,7 +331,6 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
-    device_count = serializers.IntegerField(read_only=True)
 
     # Counter fields
     console_port_template_count = serializers.IntegerField(read_only=True)
@@ -339,6 +344,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     module_bay_template_count = serializers.IntegerField(read_only=True)
     inventory_item_template_count = serializers.IntegerField(read_only=True)
 
+    # Related object counts
+    device_count = RelatedObjectCountField('instances')
+
     class Meta:
         model = DeviceType
         fields = [
@@ -636,8 +644,10 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
 class DeviceRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
-    device_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = DeviceRole
@@ -651,8 +661,10 @@ class PlatformSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
     config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
-    device_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = Platform
@@ -761,7 +773,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
     status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
 
     # Related object counts
-    interface_count = serializers.IntegerField(read_only=True)
+    interface_count = RelatedObjectCountField('interfaces')
 
     class Meta:
         model = VirtualDeviceContext
@@ -1092,7 +1104,9 @@ class InventoryItemSerializer(NetBoxModelSerializer):
 
 class InventoryItemRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
-    inventoryitem_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    inventoryitem_count = RelatedObjectCountField('inventory_items')
 
     class Meta:
         model = InventoryItemRole
@@ -1204,7 +1218,9 @@ class PowerPanelSerializer(NetBoxModelSerializer):
         allow_null=True,
         default=None
     )
-    powerfeed_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    powerfeed_count = RelatedObjectCountField('powerfeeds')
 
     class Meta:
         model = PowerPanel

+ 10 - 42
netbox/dcim/api/views.py

@@ -13,7 +13,6 @@ from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
-from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
@@ -23,7 +22,6 @@ from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
-from virtualization.models import VirtualMachine
 from . import serializers
 from .exceptions import MissingFilterException
 
@@ -129,14 +127,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class SiteViewSet(NetBoxModelViewSet):
-    queryset = Site.objects.annotate(
-        device_count=count_related(Device, 'site'),
-        rack_count=count_related(Rack, 'site'),
-        prefix_count=count_related(Prefix, 'site'),
-        vlan_count=count_related(VLAN, 'site'),
-        circuit_count=count_related(Circuit, 'terminations__site'),
-        virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
-    )
+    queryset = Site.objects.all()
     serializer_class = serializers.SiteSerializer
     filterset_class = filtersets.SiteFilterSet
 
@@ -168,9 +159,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class RackRoleViewSet(NetBoxModelViewSet):
-    queryset = RackRole.objects.annotate(
-        rack_count=count_related(Rack, 'role')
-    )
+    queryset = RackRole.objects.all()
     serializer_class = serializers.RackRoleSerializer
     filterset_class = filtersets.RackRoleFilterSet
 
@@ -180,10 +169,7 @@ class RackRoleViewSet(NetBoxModelViewSet):
 #
 
 class RackViewSet(NetBoxModelViewSet):
-    queryset = Rack.objects.annotate(
-        device_count=count_related(Device, 'rack'),
-        powerfeed_count=count_related(PowerFeed, 'rack')
-    )
+    queryset = Rack.objects.all()
     serializer_class = serializers.RackSerializer
     filterset_class = filtersets.RackFilterSet
 
@@ -255,11 +241,7 @@ class RackReservationViewSet(NetBoxModelViewSet):
 #
 
 class ManufacturerViewSet(NetBoxModelViewSet):
-    queryset = Manufacturer.objects.annotate(
-        devicetype_count=count_related(DeviceType, 'manufacturer'),
-        inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
-        platform_count=count_related(Platform, 'manufacturer')
-    )
+    queryset = Manufacturer.objects.all()
     serializer_class = serializers.ManufacturerSerializer
     filterset_class = filtersets.ManufacturerFilterSet
 
@@ -269,9 +251,7 @@ class ManufacturerViewSet(NetBoxModelViewSet):
 #
 
 class DeviceTypeViewSet(NetBoxModelViewSet):
-    queryset = DeviceType.objects.annotate(
-        device_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     serializer_class = serializers.DeviceTypeSerializer
     filterset_class = filtersets.DeviceTypeFilterSet
 
@@ -351,10 +331,7 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class DeviceRoleViewSet(NetBoxModelViewSet):
-    queryset = DeviceRole.objects.annotate(
-        device_count=count_related(Device, 'role'),
-        virtualmachine_count=count_related(VirtualMachine, 'role')
-    )
+    queryset = DeviceRole.objects.all()
     serializer_class = serializers.DeviceRoleSerializer
     filterset_class = filtersets.DeviceRoleFilterSet
 
@@ -364,10 +341,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 #
 
 class PlatformViewSet(NetBoxModelViewSet):
-    queryset = Platform.objects.annotate(
-        device_count=count_related(Device, 'platform'),
-        virtualmachine_count=count_related(VirtualMachine, 'platform')
-    )
+    queryset = Platform.objects.all()
     serializer_class = serializers.PlatformSerializer
     filterset_class = filtersets.PlatformFilterSet
 
@@ -410,9 +384,7 @@ class DeviceViewSet(
 
 
 class VirtualDeviceContextViewSet(NetBoxModelViewSet):
-    queryset = VirtualDeviceContext.objects.annotate(
-        interface_count=count_related(Interface, 'vdcs'),
-    )
+    queryset = VirtualDeviceContext.objects.all()
     serializer_class = serializers.VirtualDeviceContextSerializer
     filterset_class = filtersets.VirtualDeviceContextFilterSet
 
@@ -513,9 +485,7 @@ class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class InventoryItemRoleViewSet(NetBoxModelViewSet):
-    queryset = InventoryItemRole.objects.annotate(
-        inventoryitem_count=count_related(InventoryItem, 'role')
-    )
+    queryset = InventoryItemRole.objects.all()
     serializer_class = serializers.InventoryItemRoleSerializer
     filterset_class = filtersets.InventoryItemRoleFilterSet
 
@@ -552,9 +522,7 @@ class VirtualChassisViewSet(NetBoxModelViewSet):
 #
 
 class PowerPanelViewSet(NetBoxModelViewSet):
-    queryset = PowerPanel.objects.annotate(
-        powerfeed_count=count_related(PowerFeed, 'power_panel')
-    )
+    queryset = PowerPanel.objects.all()
     serializer_class = serializers.PowerPanelSerializer
     filterset_class = filtersets.PowerPanelFilterSet
 

+ 1 - 1
netbox/dcim/migrations/0002_squashed.py

@@ -233,7 +233,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='powerfeed',
             name='rack',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.rack'),
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.rack'),
         ),
         migrations.AddField(
             model_name='powerfeed',

+ 1 - 0
netbox/dcim/models/power.py

@@ -84,6 +84,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
     rack = models.ForeignKey(
         to='Rack',
         on_delete=models.PROTECT,
+        related_name='powerfeeds',
         blank=True,
         null=True
     )

+ 4 - 3
netbox/extras/api/serializers.py

@@ -3,7 +3,6 @@ from django.core.exceptions import ObjectDoesNotExist
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
-from rest_framework.fields import ListField
 
 from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
 from core.api.serializers import JobSerializer
@@ -16,7 +15,7 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
 from extras.choices import *
 from extras.models import *
 from netbox.api.exceptions import SerializerNotFound
-from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
 from netbox.api.serializers.features import TaggableModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -288,7 +287,9 @@ class TagSerializer(ValidatedModelSerializer):
         many=True,
         required=False
     )
-    tagged_items = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    tagged_items = RelatedObjectCountField('extras_taggeditem_items')
 
     class Meta:
         model = Tag

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

@@ -23,7 +23,7 @@ from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.exceptions import RQWorkerNotRunningException
-from utilities.utils import copy_safe_request, count_related
+from utilities.utils import copy_safe_request
 from . import serializers
 from .mixins import ConfigTemplateRenderMixin
 
@@ -147,9 +147,7 @@ class BookmarkViewSet(NetBoxModelViewSet):
 #
 
 class TagViewSet(NetBoxModelViewSet):
-    queryset = Tag.objects.annotate(
-        tagged_items=count_related(TaggedItem, 'tag')
-    )
+    queryset = Tag.objects.all()
     serializer_class = serializers.TagSerializer
     filterset_class = filtersets.TagFilterSet
 

+ 6 - 5
netbox/ipam/api/nested_serializers.py

@@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from ipam import models
+from netbox.api.fields import RelatedObjectCountField
 from netbox.api.serializers import WritableNestedSerializer
 from .field_serializers import IPAddressField
 
@@ -58,7 +59,7 @@ class NestedASNSerializer(WritableNestedSerializer):
 )
 class NestedVRFSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
-    prefix_count = serializers.IntegerField(read_only=True)
+    prefix_count = RelatedObjectCountField('prefixes')
 
     class Meta:
         model = models.VRF
@@ -86,7 +87,7 @@ class NestedRouteTargetSerializer(WritableNestedSerializer):
 )
 class NestedRIRSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
-    aggregate_count = serializers.IntegerField(read_only=True)
+    aggregate_count = RelatedObjectCountField('aggregates')
 
     class Meta:
         model = models.RIR
@@ -132,8 +133,8 @@ class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
 )
 class NestedRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
-    prefix_count = serializers.IntegerField(read_only=True)
-    vlan_count = serializers.IntegerField(read_only=True)
+    prefix_count = RelatedObjectCountField('prefixes')
+    vlan_count = RelatedObjectCountField('vlans')
 
     class Meta:
         model = models.Role
@@ -145,7 +146,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
 )
 class NestedVLANGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
-    vlan_count = serializers.IntegerField(read_only=True)
+    vlan_count = RelatedObjectCountField('vlans')
 
     class Meta:
         model = models.VLANGroup

+ 22 - 10
netbox/ipam/api/serializers.py

@@ -6,7 +6,7 @@ from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerial
 from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.models import *
-from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -43,8 +43,10 @@ class ASNSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
     rir = NestedRIRSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    site_count = serializers.IntegerField(read_only=True)
-    provider_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    site_count = RelatedObjectCountField('sites')
+    provider_count = RelatedObjectCountField('providers')
 
     class Meta:
         model = ASN
@@ -90,8 +92,10 @@ class VRFSerializer(NetBoxModelSerializer):
         required=False,
         many=True
     )
-    ipaddress_count = serializers.IntegerField(read_only=True)
-    prefix_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    ipaddress_count = RelatedObjectCountField('ip_addresses')
+    prefix_count = RelatedObjectCountField('prefixes')
 
     class Meta:
         model = VRF
@@ -124,7 +128,9 @@ class RouteTargetSerializer(NetBoxModelSerializer):
 
 class RIRSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
-    aggregate_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    aggregate_count = RelatedObjectCountField('aggregates')
 
     class Meta:
         model = RIR
@@ -195,8 +201,10 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
 
 class RoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
-    prefix_count = serializers.IntegerField(read_only=True)
-    vlan_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    prefix_count = RelatedObjectCountField('prefixes')
+    vlan_count = RelatedObjectCountField('vlans')
 
     class Meta:
         model = Role
@@ -218,9 +226,11 @@ class VLANGroupSerializer(NetBoxModelSerializer):
     )
     scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
     scope = serializers.SerializerMethodField(read_only=True)
-    vlan_count = serializers.IntegerField(read_only=True)
     utilization = serializers.CharField(read_only=True)
 
+    # Related object counts
+    vlan_count = RelatedObjectCountField('vlans')
+
     class Meta:
         model = VLANGroup
         fields = [
@@ -247,7 +257,9 @@ class VLANSerializer(NetBoxModelSerializer):
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
-    prefix_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    prefix_count = RelatedObjectCountField('prefixes')
 
     class Meta:
         model = VLAN

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

@@ -12,8 +12,6 @@ from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.views import APIView
 
-from circuits.models import Provider
-from dcim.models import Site
 from ipam import filtersets
 from ipam.models import *
 from ipam.utils import get_next_available_prefix
@@ -22,7 +20,6 @@ from netbox.api.viewsets.mixins import ObjectValidationMixin
 from netbox.config import get_config
 from netbox.constants import ADVISORY_LOCK_KEYS
 from utilities.api import get_serializer_for_model
-from utilities.utils import count_related
 from . import serializers
 
 
@@ -45,19 +42,13 @@ class ASNRangeViewSet(NetBoxModelViewSet):
 
 
 class ASNViewSet(NetBoxModelViewSet):
-    queryset = ASN.objects.annotate(
-        site_count=count_related(Site, 'asns'),
-        provider_count=count_related(Provider, 'asns')
-    )
+    queryset = ASN.objects.all()
     serializer_class = serializers.ASNSerializer
     filterset_class = filtersets.ASNFilterSet
 
 
 class VRFViewSet(NetBoxModelViewSet):
-    queryset = VRF.objects.annotate(
-        ipaddress_count=count_related(IPAddress, 'vrf'),
-        prefix_count=count_related(Prefix, 'vrf')
-    )
+    queryset = VRF.objects.all()
     serializer_class = serializers.VRFSerializer
     filterset_class = filtersets.VRFFilterSet
 
@@ -69,9 +60,7 @@ class RouteTargetViewSet(NetBoxModelViewSet):
 
 
 class RIRViewSet(NetBoxModelViewSet):
-    queryset = RIR.objects.annotate(
-        aggregate_count=count_related(Aggregate, 'rir')
-    )
+    queryset = RIR.objects.all()
     serializer_class = serializers.RIRSerializer
     filterset_class = filtersets.RIRFilterSet
 
@@ -83,10 +72,7 @@ class AggregateViewSet(NetBoxModelViewSet):
 
 
 class RoleViewSet(NetBoxModelViewSet):
-    queryset = Role.objects.annotate(
-        prefix_count=count_related(Prefix, 'role'),
-        vlan_count=count_related(VLAN, 'role')
-    )
+    queryset = Role.objects.all()
     serializer_class = serializers.RoleSerializer
     filterset_class = filtersets.RoleFilterSet
 
@@ -151,8 +137,6 @@ class VLANGroupViewSet(NetBoxModelViewSet):
 class VLANViewSet(NetBoxModelViewSet):
     queryset = VLAN.objects.prefetch_related(
         'l2vpn_terminations',  # Referenced by VLANSerializer.l2vpn_termination
-    ).annotate(
-        prefix_count=count_related(Prefix, 'vlan')
     )
     serializer_class = serializers.VLANSerializer
     filterset_class = filtersets.VLANFilterSet

+ 15 - 1
netbox/netbox/api/fields.py

@@ -1,6 +1,6 @@
 from django.core.exceptions import ObjectDoesNotExist
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from netaddr import IPNetwork
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
@@ -10,6 +10,7 @@ __all__ = (
     'ChoiceField',
     'ContentTypeField',
     'IPNetworkSerializer',
+    'RelatedObjectCountField',
     'SerializedPKRelatedField',
 )
 
@@ -135,3 +136,16 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField):
 
     def to_representation(self, value):
         return self.serializer(value, context={'request': self.context['request']}).data
+
+
+@extend_schema_field(OpenApiTypes.INT64)
+class RelatedObjectCountField(serializers.ReadOnlyField):
+    """
+    Represents a read-only integer count of related objects (e.g. the number of racks assigned to a site). This field
+    is detected by get_annotations_for_serializer() when determining the annotations to be added to a queryset
+    depending on the serializer fields selected for inclusion in the response.
+    """
+    def __init__(self, relation, **kwargs):
+        self.relation = relation
+
+        super().__init__(**kwargs)

+ 7 - 6
netbox/netbox/api/viewsets/__init__.py

@@ -10,7 +10,7 @@ from rest_framework import mixins as drf_mixins
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 
-from utilities.api import get_prefetches_for_serializer
+from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
 from utilities.exceptions import AbortRequest
 from . import mixins
 
@@ -44,15 +44,16 @@ class BaseViewSet(GenericViewSet):
 
     def get_queryset(self):
         qs = super().get_queryset()
+        serializer_class = self.get_serializer_class()
 
         # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
-        prefetch = get_prefetches_for_serializer(
-            self.get_serializer_class(),
-            fields_to_include=self.requested_fields
-        )
-        if prefetch:
+        if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields):
             qs = qs.prefetch_related(*prefetch)
 
+        # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
+        if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields):
+            qs = qs.annotate(**annotations)
+
         return qs
 
     def get_serializer(self, *args, **kwargs):

+ 0 - 13
netbox/netbox/api/viewsets/mixins.py

@@ -52,19 +52,6 @@ class BriefModeMixin:
 
         return self.serializer_class
 
-    def get_queryset(self):
-        qs = super().get_queryset()
-
-        if self.brief:
-            serializer_class = self.get_serializer_class()
-
-            # Clear any annotations for fields not present on the nested serializer
-            for annotation in list(qs.query.annotations.keys()):
-                if annotation not in serializer_class().fields:
-                    qs.query.annotations.pop(annotation)
-
-        return qs
-
 
 class CustomFieldsMixin:
     """

+ 13 - 11
netbox/tenancy/api/serializers.py

@@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.choices import ContactPriorityChoices
@@ -32,16 +32,18 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
 class TenantSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
     group = NestedTenantGroupSerializer(required=False, allow_null=True)
-    circuit_count = serializers.IntegerField(read_only=True)
-    device_count = serializers.IntegerField(read_only=True)
-    ipaddress_count = serializers.IntegerField(read_only=True)
-    prefix_count = serializers.IntegerField(read_only=True)
-    rack_count = serializers.IntegerField(read_only=True)
-    site_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
-    vlan_count = serializers.IntegerField(read_only=True)
-    vrf_count = serializers.IntegerField(read_only=True)
-    cluster_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    circuit_count = RelatedObjectCountField('circuits')
+    device_count = RelatedObjectCountField('devices')
+    rack_count = RelatedObjectCountField('racks')
+    site_count = RelatedObjectCountField('sites')
+    ipaddress_count = RelatedObjectCountField('ip_addresses')
+    prefix_count = RelatedObjectCountField('prefixes')
+    vlan_count = RelatedObjectCountField('vlans')
+    vrf_count = RelatedObjectCountField('vrfs')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+    cluster_count = RelatedObjectCountField('clusters')
 
     class Meta:
         model = Tenant

+ 1 - 17
netbox/tenancy/api/views.py

@@ -1,13 +1,8 @@
 from rest_framework.routers import APIRootView
 
-from circuits.models import Circuit
-from dcim.models import Device, Rack, Site
-from ipam.models import IPAddress, Prefix, VLAN, VRF
 from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from tenancy import filtersets
 from tenancy.models import *
-from utilities.utils import count_related
-from virtualization.models import VirtualMachine, Cluster
 from . import serializers
 
 
@@ -36,18 +31,7 @@ class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 
 
 class TenantViewSet(NetBoxModelViewSet):
-    queryset = Tenant.objects.annotate(
-        circuit_count=count_related(Circuit, 'tenant'),
-        device_count=count_related(Device, 'tenant'),
-        ipaddress_count=count_related(IPAddress, 'tenant'),
-        prefix_count=count_related(Prefix, 'tenant'),
-        rack_count=count_related(Rack, 'tenant'),
-        site_count=count_related(Site, 'tenant'),
-        virtualmachine_count=count_related(VirtualMachine, 'tenant'),
-        vlan_count=count_related(VLAN, 'tenant'),
-        vrf_count=count_related(VRF, 'tenant'),
-        cluster_count=count_related(Cluster, 'tenant')
-    )
+    queryset = Tenant.objects.all()
     serializer_class = serializers.TenantSerializer
     filterset_class = filtersets.TenantFilterSet
 

+ 23 - 0
netbox/utilities/api.py

@@ -11,10 +11,13 @@ from rest_framework import status
 from rest_framework.serializers import Serializer
 from rest_framework.utils import formatting
 
+from netbox.api.fields import RelatedObjectCountField
 from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
+from utilities.utils import count_related
 from .utils import dynamic_import
 
 __all__ = (
+    'get_annotations_for_serializer',
     'get_graphql_type_for_model',
     'get_prefetches_for_serializer',
     'get_serializer_for_model',
@@ -131,6 +134,26 @@ def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
     return prefetch_fields
 
 
+def get_annotations_for_serializer(serializer_class, fields_to_include=None):
+    """
+    Return a mapping of field names to annotations to be applied to the queryset for a serializer.
+    """
+    annotations = {}
+
+    # If specific fields are not specified, default to all
+    if not fields_to_include:
+        fields_to_include = serializer_class.Meta.fields
+
+    model = serializer_class.Meta.model
+
+    for field_name, field in serializer_class._declared_fields.items():
+        if field_name in fields_to_include and type(field) is RelatedObjectCountField:
+            related_field = model._meta.get_field(field.relation).field
+            annotations[field_name] = count_related(related_field.model, related_field.name)
+
+    return annotations
+
+
 def rest_api_server_error(request, *args, **kwargs):
     """
     Handle exceptions and return a useful error message for REST API requests.

+ 4 - 3
netbox/virtualization/api/nested_serializers.py

@@ -1,6 +1,7 @@
 from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
+from netbox.api.fields import RelatedObjectCountField
 from netbox.api.serializers import WritableNestedSerializer
 from virtualization.models import *
 
@@ -23,7 +24,7 @@ __all__ = [
 )
 class NestedClusterTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
-    cluster_count = serializers.IntegerField(read_only=True)
+    cluster_count = RelatedObjectCountField('clusters')
 
     class Meta:
         model = ClusterType
@@ -35,7 +36,7 @@ class NestedClusterTypeSerializer(WritableNestedSerializer):
 )
 class NestedClusterGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
-    cluster_count = serializers.IntegerField(read_only=True)
+    cluster_count = RelatedObjectCountField('clusters')
 
     class Meta:
         model = ClusterGroup
@@ -47,7 +48,7 @@ class NestedClusterGroupSerializer(WritableNestedSerializer):
 )
 class NestedClusterSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = Cluster

+ 11 - 5
netbox/virtualization/api/serializers.py

@@ -8,7 +8,7 @@ from dcim.choices import InterfaceModeChoices
 from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.models import VLAN
-from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
@@ -23,7 +23,9 @@ from .nested_serializers import *
 
 class ClusterTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
-    cluster_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    cluster_count = RelatedObjectCountField('clusters')
 
     class Meta:
         model = ClusterType
@@ -35,7 +37,9 @@ class ClusterTypeSerializer(NetBoxModelSerializer):
 
 class ClusterGroupSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
-    cluster_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    cluster_count = RelatedObjectCountField('clusters')
 
     class Meta:
         model = ClusterGroup
@@ -52,8 +56,10 @@ class ClusterSerializer(NetBoxModelSerializer):
     status = ChoiceField(choices=ClusterStatusChoices, required=False)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True, default=None)
-    device_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
 
     class Meta:
         model = Cluster

+ 3 - 12
netbox/virtualization/api/views.py

@@ -1,10 +1,8 @@
 from rest_framework.routers import APIRootView
 
-from dcim.models import Device
 from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
 from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.query_functions import CollateAsChar
-from utilities.utils import count_related
 from virtualization import filtersets
 from virtualization.models import *
 from . import serializers
@@ -23,26 +21,19 @@ class VirtualizationRootView(APIRootView):
 #
 
 class ClusterTypeViewSet(NetBoxModelViewSet):
-    queryset = ClusterType.objects.annotate(
-        cluster_count=count_related(Cluster, 'type')
-    )
+    queryset = ClusterType.objects.all()
     serializer_class = serializers.ClusterTypeSerializer
     filterset_class = filtersets.ClusterTypeFilterSet
 
 
 class ClusterGroupViewSet(NetBoxModelViewSet):
-    queryset = ClusterGroup.objects.annotate(
-        cluster_count=count_related(Cluster, 'group')
-    )
+    queryset = ClusterGroup.objects.all()
     serializer_class = serializers.ClusterGroupSerializer
     filterset_class = filtersets.ClusterGroupFilterSet
 
 
 class ClusterViewSet(NetBoxModelViewSet):
-    queryset = Cluster.objects.annotate(
-        device_count=count_related(Device, 'cluster'),
-        virtualmachine_count=count_related(VirtualMachine, 'cluster')
-    )
+    queryset = Cluster.objects.all()
     serializer_class = serializers.ClusterSerializer
     filterset_class = filtersets.ClusterFilterSet
 

+ 2 - 1
netbox/vpn/api/nested_serializers.py

@@ -1,6 +1,7 @@
 from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
+from netbox.api.fields import RelatedObjectCountField
 from netbox.api.serializers import WritableNestedSerializer
 from vpn import models
 
@@ -23,7 +24,7 @@ __all__ = (
 )
 class NestedTunnelGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
-    tunnel_count = serializers.IntegerField(read_only=True)
+    tunnel_count = RelatedObjectCountField('tunnels')
 
     class Meta:
         model = models.TunnelGroup

+ 8 - 3
netbox/vpn/api/serializers.py

@@ -4,7 +4,7 @@ from rest_framework import serializers
 
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedRouteTargetSerializer
 from ipam.models import RouteTarget
-from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -29,7 +29,9 @@ __all__ = (
 
 class TunnelGroupSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
-    tunnel_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    tunnel_count = RelatedObjectCountField('tunnels')
 
     class Meta:
         model = TunnelGroup
@@ -59,11 +61,14 @@ class TunnelSerializer(NetBoxModelSerializer):
         allow_null=True
     )
 
+    # Related object counts
+    terminations_count = RelatedObjectCountField('terminations')
+
     class Meta:
         model = Tunnel
         fields = (
             'id', 'url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'terminations_count',
         )
 
 

+ 2 - 7
netbox/vpn/api/views.py

@@ -1,7 +1,6 @@
 from rest_framework.routers import APIRootView
 
 from netbox.api.viewsets import NetBoxModelViewSet
-from utilities.utils import count_related
 from vpn import filtersets
 from vpn.models import *
 from . import serializers
@@ -34,17 +33,13 @@ class VPNRootView(APIRootView):
 #
 
 class TunnelGroupViewSet(NetBoxModelViewSet):
-    queryset = TunnelGroup.objects.annotate(
-        tunnel_count=count_related(Tunnel, 'group')
-    )
+    queryset = TunnelGroup.objects.all()
     serializer_class = serializers.TunnelGroupSerializer
     filterset_class = filtersets.TunnelGroupFilterSet
 
 
 class TunnelViewSet(NetBoxModelViewSet):
-    queryset = Tunnel.objects.annotate(
-        terminations_count=count_related(TunnelTermination, 'tunnel')
-    )
+    queryset = Tunnel.objects.all()
     serializer_class = serializers.TunnelSerializer
     filterset_class = filtersets.TunnelFilterSet