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

feat(dcim): Add device, module and rack count filters

Introduces `device_count`, `module_count` and `rack_count` filters to
enable queries based on the existence and count of the associated
device, module or rack instances.
Updates forms, filtersets, and GraphQL schema to support these filters,
along with tests for validation.

Fixes #19523
Martin Hauser 3 месяцев назад
Родитель
Сommit
cee2a5e0ed

+ 5 - 6
netbox/dcim/api/serializers_/devicetypes.py

@@ -5,7 +5,7 @@ from rest_framework import serializers
 
 from dcim.choices import *
 from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
-from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
+from netbox.api.fields import AttributesField, ChoiceField
 from netbox.api.serializers import PrimaryModelSerializer
 from netbox.choices import *
 from .manufacturers import ManufacturerSerializer
@@ -45,9 +45,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
     device_bay_template_count = serializers.IntegerField(read_only=True)
     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')
+    device_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = DeviceType
@@ -100,12 +98,13 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
         required=False,
         allow_null=True
     )
+    module_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = ModuleType
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
+            'created', 'last_updated', 'module_count',
         ]
-        brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')
+        brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')

+ 4 - 5
netbox/dcim/api/serializers_/racks.py

@@ -62,9 +62,8 @@ class RackBaseSerializer(PrimaryModelSerializer):
 
 
 class RackTypeSerializer(RackBaseSerializer):
-    manufacturer = ManufacturerSerializer(
-        nested=True
-    )
+    manufacturer = ManufacturerSerializer(nested=True)
+    rack_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = RackType
@@ -72,9 +71,9 @@ class RackTypeSerializer(RackBaseSerializer):
             'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
             'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
             'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated',
+            'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
         ]
-        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
 
 
 class RackSerializer(RackBaseSerializer):

+ 2 - 2
netbox/dcim/apps.py

@@ -11,7 +11,7 @@ class DCIMConfig(AppConfig):
         from netbox.models.features import register_models
         from utilities.counters import connect_counters
         from . import signals, search  # noqa: F401
-        from .models import CableTermination, Device, DeviceType, VirtualChassis
+        from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
 
         # Register models
         register_models(*self.get_models())
@@ -31,4 +31,4 @@ class DCIMConfig(AppConfig):
         })
 
         # Register counters
-        connect_counters(Device, DeviceType, VirtualChassis)
+        connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis)

+ 10 - 1
netbox/dcim/filtersets.py

@@ -317,6 +317,9 @@ class RackTypeFilterSet(PrimaryModelFilterSet):
         fields = (
             'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
             'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
+
+            # Counters
+            'rack_count',
         )
 
     def search(self, queryset, name, value):
@@ -627,6 +630,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
             'device_bay_template_count',
             'module_bay_template_count',
             'inventory_item_template_count',
+            'device_count',
         )
 
     def search(self, queryset, name, value):
@@ -747,7 +751,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
 
     class Meta:
         model = ModuleType
-        fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
+        fields = (
+            'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
+
+            # Counters
+            'module_count',
+        )
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 22 - 3
netbox/dcim/forms/filtersets.py

@@ -317,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
     model = RackType
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
-        FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
+        FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
@@ -327,6 +327,11 @@ class RackTypeFilterForm(RackBaseFilterForm):
         required=False,
         label=_('Manufacturer')
     )
+    rack_count = forms.IntegerField(
+        label=_('Rack count'),
+        required=False,
+        min_value=0,
+    )
     tag = TagFilterField(model)
 
 
@@ -498,7 +503,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet(
-            'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware')
+            'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
+            'subdevice_role', 'airflow', name=_('Hardware')
         ),
         FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
         FieldSet(
@@ -522,6 +528,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         label=_('Part number'),
         required=False
     )
+    device_count = forms.IntegerField(
+        label=_('Device count'),
+        required=False,
+        min_value=0,
+    )
     subdevice_role = forms.MultipleChoiceField(
         label=_('Subdevice role'),
         choices=add_blank_choice(SubdeviceRoleChoices),
@@ -633,7 +644,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
     model = ModuleType
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
-        FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
+        FieldSet(
+            'profile_id', 'manufacturer_id', 'part_number', 'module_count',
+            'airflow', name=_('Hardware')
+        ),
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports', name=_('Components')
@@ -655,6 +669,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         label=_('Part number'),
         required=False
     )
+    module_count = forms.IntegerField(
+        label=_('Module count'),
+        required=False,
+        min_value=0,
+    )
     console_ports = forms.NullBooleanField(
         required=False,
         label=_('Has console ports'),

+ 11 - 1
netbox/dcim/graphql/filters.py

@@ -4,7 +4,7 @@ from django.db.models import Q
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import FilterLookup
+from strawberry_django import ComparisonFilterLookup, FilterLookup
 
 from core.graphql.filter_mixins import ChangeLogFilterMixin
 from dcim import models
@@ -328,6 +328,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     )
     default_platform_id: ID | None = strawberry_django.filter_field()
     part_number: FilterLookup[str] | None = strawberry_django.filter_field()
+    instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
     u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
@@ -385,6 +388,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
     module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
     inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+    device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.FrontPort, lookups=True)
@@ -685,6 +689,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     profile_id: ID | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     part_number: FilterLookup[str] | None = strawberry_django.filter_field()
+    instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
     airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
         strawberry_django.filter_field()
     )
@@ -718,6 +725,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     inventory_item_templates: (
         Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
     ) = strawberry_django.filter_field()
+    module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.Platform, lookups=True)
@@ -846,6 +854,8 @@ class RackTypeFilter(RackBaseFilterMixin):
     manufacturer_id: ID | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+    rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.Rack, lookups=True)

+ 3 - 0
netbox/dcim/graphql/types.py

@@ -358,6 +358,7 @@ class DeviceTypeType(PrimaryObjectType):
     device_bay_template_count: BigInt
     module_bay_template_count: BigInt
     inventory_item_template_count: BigInt
+    device_count: BigInt
     front_image: strawberry_django.fields.types.DjangoImageType | None
     rear_image: strawberry_django.fields.types.DjangoImageType | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -605,6 +606,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
     pagination=True
 )
 class ModuleTypeType(PrimaryObjectType):
+    module_count: BigInt
     profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
@@ -709,6 +711,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     pagination=True
 )
 class RackTypeType(PrimaryObjectType):
+    rack_count: BigInt
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
 

+ 66 - 0
netbox/dcim/migrations/0218_devicetype_device_count.py

@@ -0,0 +1,66 @@
+import utilities.fields
+from django.db import migrations
+from django.db.models import Count, OuterRef, Subquery
+
+
+def _populate_count_for_type(
+    apps, schema_editor, app_name: str, model_name: str, target_field: str, related_name: str = 'instances'
+):
+    """
+    Update a CounterCache field on the specified model by annotating the count of related instances.
+    """
+    Model = apps.get_model(app_name, model_name)
+    db_alias = schema_editor.connection.alias
+
+    count_subquery = (
+        Model.objects.using(db_alias)
+        .filter(pk=OuterRef('pk'))
+        .annotate(_instance_count=Count(related_name))
+        .values('_instance_count')
+    )
+    Model.objects.using(db_alias).update(**{target_field: Subquery(count_subquery)})
+
+
+def populate_device_type_device_count(apps, schema_editor):
+    _populate_count_for_type(apps, schema_editor, 'dcim', 'DeviceType', 'device_count')
+
+
+def populate_module_type_module_count(apps, schema_editor):
+    _populate_count_for_type(apps, schema_editor, 'dcim', 'ModuleType', 'module_count')
+
+
+def populate_rack_type_rack_count(apps, schema_editor):
+    _populate_count_for_type(apps, schema_editor, 'dcim', 'RackType', 'rack_count', related_name='racks')
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0217_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicetype',
+            name='device_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='device_type', to_model='dcim.Device'
+            ),
+        ),
+        migrations.RunPython(populate_device_type_device_count, migrations.RunPython.noop),
+        migrations.AddField(
+            model_name='moduletype',
+            name='module_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.Module'
+            ),
+        ),
+        migrations.RunPython(populate_module_type_module_count, migrations.RunPython.noop),
+        migrations.AddField(
+            model_name='racktype',
+            name='rack_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='rack_type', to_model='dcim.Rack'
+            ),
+        ),
+        migrations.RunPython(populate_rack_type_rack_count, migrations.RunPython.noop),
+    ]

+ 4 - 0
netbox/dcim/models/devices.py

@@ -185,6 +185,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_model='dcim.InventoryItemTemplate',
         to_field='device_type'
     )
+    device_count = CounterCacheField(
+        to_model='dcim.Device',
+        to_field='device_type'
+    )
 
     clone_fields = (
         'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',

+ 7 - 1
netbox/dcim/models/modules.py

@@ -13,8 +13,10 @@ from extras.models import ConfigContextModel, CustomField
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
+from utilities.fields import CounterCacheField
 from utilities.jsonschema import validate_schema
 from utilities.string import title
+from utilities.tracking import TrackingModelMixin
 from .device_components import *
 
 __all__ = (
@@ -92,6 +94,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         null=True,
         verbose_name=_('attributes')
     )
+    module_count = CounterCacheField(
+        to_model='dcim.Module',
+        to_field='module_type'
+    )
 
     clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
     prerequisite_models = (
@@ -186,7 +192,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         return yaml.dump(dict(data), sort_keys=False)
 
 
-class Module(PrimaryModel, ConfigContextModel):
+class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
     """
     A Module represents a field-installable component within a Device which may itself hold multiple device components
     (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.

+ 12 - 5
netbox/dcim/models/racks.py

@@ -19,9 +19,11 @@ from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.conversion import to_grams
 from utilities.data import array_to_string, drange
-from utilities.fields import ColorField
+from utilities.fields import ColorField, CounterCacheField
+from utilities.tracking import TrackingModelMixin
 from .device_components import PowerPort
-from .devices import Device, Module
+from .devices import Device
+from .modules import Module
 from .power import PowerFeed
 
 __all__ = (
@@ -144,6 +146,10 @@ class RackType(RackBase):
         max_length=100,
         unique=True
     )
+    rack_count = CounterCacheField(
+        to_model='dcim.Rack',
+        to_field='rack_type'
+    )
 
     clone_fields = (
         'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
@@ -234,7 +240,7 @@ class RackRole(OrganizationalModel):
         verbose_name_plural = _('rack roles')
 
 
-class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
+class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Each Rack is assigned to a Site and (optionally) a Location.
@@ -509,7 +515,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
 
         return [u for u in elevation.values()]
 
-    def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False):
+    def get_available_units(self, u_height=1.0, rack_face=None, exclude=None, ignore_excluded_devices=False):
         """
         Return a list of units within the rack available to accommodate a device of a given U height (default 1).
         Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
@@ -581,9 +587,10 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
         :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
             height of the elevation
         :param legend_width: Width of the unit legend, in pixels
-        :param margin_width: Width of the rigth-hand margin, in pixels
+        :param margin_width: Width of the right-hand margin, in pixels
         :param include_images: Embed front/rear device images where available
         :param base_url: Base URL for links and images. If none, URLs will be relative.
+        :param highlight_params: Dictionary of parameters to be passed to the RackElevationSVG.render_highlight() method
         """
         elevation = RackElevationSVG(
             self,

+ 4 - 4
netbox/dcim/tables/devicetypes.py

@@ -109,10 +109,10 @@ class DeviceTypeTable(PrimaryModelTable):
         template_code=WEIGHT,
         order_by=('_abs_weight', 'weight_unit')
     )
-    instance_count = columns.LinkedCountColumn(
+    device_count = columns.LinkedCountColumn(
         viewname='dcim:device_list',
         url_params={'device_type_id': 'pk'},
-        verbose_name=_('Instances')
+        verbose_name=_('Device Count'),
     )
     console_port_template_count = tables.Column(
         verbose_name=_('Console Ports')
@@ -150,10 +150,10 @@ class DeviceTypeTable(PrimaryModelTable):
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
             'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
-            'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
+            'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
+            'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'device_count',
         )
 
 

+ 4 - 4
netbox/dcim/tables/modules.py

@@ -56,10 +56,10 @@ class ModuleTypeTable(PrimaryModelTable):
         order_by=('_abs_weight', 'weight_unit')
     )
     attributes = columns.DictColumn()
-    instance_count = columns.LinkedCountColumn(
+    module_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
-        verbose_name=_('Instances')
+        verbose_name=_('Module Count'),
     )
     tags = columns.TagColumn(
         url_name='dcim:moduletype_list'
@@ -69,10 +69,10 @@ class ModuleTypeTable(PrimaryModelTable):
         model = ModuleType
         fields = (
             'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
-            'attributes', 'comments', 'tags', 'created', 'last_updated',
+            'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'model', 'profile', 'manufacturer', 'part_number',
+            'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count',
         )
 
 

+ 4 - 4
netbox/dcim/tables/racks.py

@@ -76,10 +76,10 @@ class RackTypeTable(PrimaryModelTable):
         template_code=WEIGHT,
         order_by=('_abs_max_weight', 'weight_unit')
     )
-    instance_count = columns.LinkedCountColumn(
+    rack_count = columns.LinkedCountColumn(
         viewname='dcim:rack_list',
         url_params={'rack_type_id': 'pk'},
-        verbose_name=_('Instances')
+        verbose_name=_('Rack Count'),
     )
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
@@ -90,10 +90,10 @@ class RackTypeTable(PrimaryModelTable):
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
             'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
-            'comments', 'instance_count', 'tags', 'created', 'last_updated',
+            'comments', 'rack_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count',
+            'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'rack_count',
         )
 
 

+ 2 - 2
netbox/dcim/tests/test_api.py

@@ -317,7 +317,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
 
 class RackTypeTest(APIViewTestCases.APIViewTestCase):
     model = RackType
-    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
+    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
     bulk_update_data = {
         'description': 'new description',
     }
@@ -610,7 +610,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
     model = ModuleType
-    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
+    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
     bulk_update_data = {
         'part_number': 'ABC123',
     }

+ 5 - 15
netbox/dcim/views.py

@@ -856,9 +856,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
 
 @register_model_view(RackType, 'list', path='', detail=False)
 class RackTypeListView(generic.ObjectListView):
-    queryset = RackType.objects.annotate(
-        instance_count=count_related(Rack, 'rack_type')
-    )
+    queryset = RackType.objects.all()
     filterset = filtersets.RackTypeFilterSet
     filterset_form = forms.RackTypeFilterForm
     table = tables.RackTypeTable
@@ -1298,9 +1296,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
 
 @register_model_view(DeviceType, 'list', path='', detail=False)
 class DeviceTypeListView(generic.ObjectListView):
-    queryset = DeviceType.objects.annotate(
-        instance_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     filterset = filtersets.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
@@ -1531,9 +1527,7 @@ class DeviceTypeImportView(generic.BulkImportView):
 
 @register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False)
 class DeviceTypeBulkEditView(generic.BulkEditView):
-    queryset = DeviceType.objects.annotate(
-        instance_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
     form = forms.DeviceTypeBulkEditForm
@@ -1548,9 +1542,7 @@ class DeviceTypeBulkRenameView(generic.BulkRenameView):
 
 @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
-    queryset = DeviceType.objects.annotate(
-        instance_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
 
@@ -1652,9 +1644,7 @@ class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
 
 @register_model_view(ModuleType, 'list', path='', detail=False)
 class ModuleTypeListView(generic.ObjectListView):
-    queryset = ModuleType.objects.annotate(
-        instance_count=count_related(Module, 'module_type')
-    )
+    queryset = ModuleType.objects.all()
     filterset = filtersets.ModuleTypeFilterSet
     filterset_form = forms.ModuleTypeFilterForm
     table = tables.ModuleTypeTable

+ 37 - 4
netbox/utilities/tests/test_counters.py

@@ -2,13 +2,14 @@ from django.test import override_settings
 from django.urls import reverse
 
 from dcim.models import *
+from utilities.counters import connect_counters
 from utilities.testing.base import TestCase
 from utilities.testing.utils import create_test_device
 
 
 class CountersTest(TestCase):
     """
-    Validate the operation of dict_to_filter_params().
+    Validate the operation of the CounterCacheField (tracking counters).
     """
     @classmethod
     def setUpTestData(cls):
@@ -24,7 +25,7 @@ class CountersTest(TestCase):
 
     def test_interface_count_creation(self):
         """
-        When a tracked object (Interface) is added the tracking counter should be updated.
+        When a tracked object (Interface) is added, the tracking counter should be updated.
         """
         device1, device2 = Device.objects.all()
         self.assertEqual(device1.interface_count, 2)
@@ -51,7 +52,7 @@ class CountersTest(TestCase):
 
     def test_interface_count_deletion(self):
         """
-        When a tracked object (Interface) is deleted the tracking counter should be updated.
+        When a tracked object (Interface) is deleted, the tracking counter should be updated.
         """
         device1, device2 = Device.objects.all()
         self.assertEqual(device1.interface_count, 2)
@@ -66,7 +67,7 @@ class CountersTest(TestCase):
 
     def test_interface_count_move(self):
         """
-        When a tracked object (Interface) is moved the tracking counter should be updated.
+        When a tracked object (Interface) is moved, the tracking counter should be updated.
         """
         device1, device2 = Device.objects.all()
         self.assertEqual(device1.interface_count, 2)
@@ -102,3 +103,35 @@ class CountersTest(TestCase):
         self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
         device1.refresh_from_db()
         self.assertEqual(device1.inventory_item_count, 0)
+
+    def test_signal_connections_are_idempotent_per_sender(self):
+        """
+        Calling connect_counters() again must not register duplicate receivers.
+        Creating a device after repeated "connect_counters" should still yield +1.
+        """
+        connect_counters(DeviceType, VirtualChassis)
+        vc, _ = VirtualChassis.objects.get_or_create(name='Virtual Chassis 1')
+        device1, device2 = Device.objects.all()
+        self.assertEqual(device1.device_type.device_count, 2)
+        self.assertEqual(vc.member_count, 0)
+
+        # Call again (should be a no-op for sender registrations)
+        connect_counters(DeviceType, VirtualChassis)
+
+        # Create one new device
+        device3 = create_test_device('Device 3')
+        device3.virtual_chassis = vc
+        device3.save()
+
+        # Ensure counter incremented correctly
+        device1.refresh_from_db()
+        vc.refresh_from_db()
+        self.assertEqual(device1.device_type.device_count, 3, 'device_count should increment exactly once')
+        self.assertEqual(vc.member_count, 1, 'member_count should increment exactly once')
+
+        # Clean up and ensure counter decremented correctly
+        device3.delete()
+        device1.refresh_from_db()
+        vc.refresh_from_db()
+        self.assertEqual(device1.device_type.device_count, 2, 'device_count should decrement exactly once')
+        self.assertEqual(vc.member_count, 0, 'member_count should decrement exactly once')