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

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.choices import *
 from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
 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.api.serializers import PrimaryModelSerializer
 from netbox.choices import *
 from netbox.choices import *
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
@@ -45,9 +45,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
     device_bay_template_count = serializers.IntegerField(read_only=True)
     device_bay_template_count = serializers.IntegerField(read_only=True)
     module_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)
     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:
     class Meta:
         model = DeviceType
         model = DeviceType
@@ -100,12 +98,13 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
         required=False,
         required=False,
         allow_null=True
         allow_null=True
     )
     )
+    module_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
             '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):
 class RackTypeSerializer(RackBaseSerializer):
-    manufacturer = ManufacturerSerializer(
-        nested=True
-    )
+    manufacturer = ManufacturerSerializer(nested=True)
+    rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RackType
         model = RackType
@@ -72,9 +71,9 @@ class RackTypeSerializer(RackBaseSerializer):
             'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
             'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
             'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
             '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',
             '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):
 class RackSerializer(RackBaseSerializer):

+ 2 - 2
netbox/dcim/apps.py

@@ -11,7 +11,7 @@ class DCIMConfig(AppConfig):
         from netbox.models.features import register_models
         from netbox.models.features import register_models
         from utilities.counters import connect_counters
         from utilities.counters import connect_counters
         from . import signals, search  # noqa: F401
         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
         register_models(*self.get_models())
         register_models(*self.get_models())
@@ -31,4 +31,4 @@ class DCIMConfig(AppConfig):
         })
         })
 
 
         # Register counters
         # 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 = (
         fields = (
             'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
             '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',
             'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
+
+            # Counters
+            'rack_count',
         )
         )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -627,6 +630,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
             'device_bay_template_count',
             'device_bay_template_count',
             'module_bay_template_count',
             'module_bay_template_count',
             'inventory_item_template_count',
             'inventory_item_template_count',
+            'device_count',
         )
         )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -747,7 +751,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ModuleType
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

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

@@ -317,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
     model = RackType
     model = RackType
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         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('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     )
@@ -327,6 +327,11 @@ class RackTypeFilterForm(RackBaseFilterForm):
         required=False,
         required=False,
         label=_('Manufacturer')
         label=_('Manufacturer')
     )
     )
+    rack_count = forms.IntegerField(
+        label=_('Rack count'),
+        required=False,
+        min_value=0,
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -498,7 +503,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         FieldSet(
         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('has_front_image', 'has_rear_image', name=_('Images')),
         FieldSet(
         FieldSet(
@@ -522,6 +528,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         label=_('Part number'),
         label=_('Part number'),
         required=False
         required=False
     )
     )
+    device_count = forms.IntegerField(
+        label=_('Device count'),
+        required=False,
+        min_value=0,
+    )
     subdevice_role = forms.MultipleChoiceField(
     subdevice_role = forms.MultipleChoiceField(
         label=_('Subdevice role'),
         label=_('Subdevice role'),
         choices=add_blank_choice(SubdeviceRoleChoices),
         choices=add_blank_choice(SubdeviceRoleChoices),
@@ -633,7 +644,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
     model = ModuleType
     model = ModuleType
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag', 'owner_id'),
         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(
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports', name=_('Components')
             'pass_through_ports', name=_('Components')
@@ -655,6 +669,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         label=_('Part number'),
         label=_('Part number'),
         required=False
         required=False
     )
     )
+    module_count = forms.IntegerField(
+        label=_('Module count'),
+        required=False,
+        min_value=0,
+    )
     console_ports = forms.NullBooleanField(
     console_ports = forms.NullBooleanField(
         required=False,
         required=False,
         label=_('Has console ports'),
         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
 import strawberry_django
 import strawberry_django
 from strawberry.scalars import ID
 from strawberry.scalars import ID
-from strawberry_django import FilterLookup
+from strawberry_django import ComparisonFilterLookup, FilterLookup
 
 
 from core.graphql.filter_mixins import ChangeLogFilterMixin
 from core.graphql.filter_mixins import ChangeLogFilterMixin
 from dcim import models
 from dcim import models
@@ -328,6 +328,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     )
     )
     default_platform_id: ID | None = strawberry_django.filter_field()
     default_platform_id: ID | None = strawberry_django.filter_field()
     part_number: FilterLookup[str] | 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 = (
     u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -385,6 +388,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
     device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
     module_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()
     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)
 @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()
     profile_id: ID | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     part_number: 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 = (
     airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -718,6 +725,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
     inventory_item_templates: (
     inventory_item_templates: (
         Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
         Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
     ) = strawberry_django.filter_field()
     ) = strawberry_django.filter_field()
+    module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.Platform, lookups=True)
 @strawberry_django.filter_type(models.Platform, lookups=True)
@@ -846,6 +854,8 @@ class RackTypeFilter(RackBaseFilterMixin):
     manufacturer_id: ID | None = strawberry_django.filter_field()
     manufacturer_id: ID | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: 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)
 @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
     device_bay_template_count: BigInt
     module_bay_template_count: BigInt
     module_bay_template_count: BigInt
     inventory_item_template_count: BigInt
     inventory_item_template_count: BigInt
+    device_count: BigInt
     front_image: strawberry_django.fields.types.DjangoImageType | None
     front_image: strawberry_django.fields.types.DjangoImageType | None
     rear_image: strawberry_django.fields.types.DjangoImageType | None
     rear_image: strawberry_django.fields.types.DjangoImageType | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -605,6 +606,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
     pagination=True
     pagination=True
 )
 )
 class ModuleTypeType(PrimaryObjectType):
 class ModuleTypeType(PrimaryObjectType):
+    module_count: BigInt
     profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
 
@@ -709,6 +711,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     pagination=True
     pagination=True
 )
 )
 class RackTypeType(PrimaryObjectType):
 class RackTypeType(PrimaryObjectType):
+    rack_count: BigInt
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
     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_model='dcim.InventoryItemTemplate',
         to_field='device_type'
         to_field='device_type'
     )
     )
+    device_count = CounterCacheField(
+        to_model='dcim.Device',
+        to_field='device_type'
+    )
 
 
     clone_fields = (
     clone_fields = (
         'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
         '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 import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
 from netbox.models.mixins import WeightMixin
+from utilities.fields import CounterCacheField
 from utilities.jsonschema import validate_schema
 from utilities.jsonschema import validate_schema
 from utilities.string import title
 from utilities.string import title
+from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .device_components import *
 
 
 __all__ = (
 __all__ = (
@@ -92,6 +94,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         null=True,
         null=True,
         verbose_name=_('attributes')
         verbose_name=_('attributes')
     )
     )
+    module_count = CounterCacheField(
+        to_model='dcim.Module',
+        to_field='module_type'
+    )
 
 
     clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
     clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
     prerequisite_models = (
     prerequisite_models = (
@@ -186,7 +192,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         return yaml.dump(dict(data), sort_keys=False)
         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
     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.
     (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 netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.conversion import to_grams
 from utilities.conversion import to_grams
 from utilities.data import array_to_string, drange
 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 .device_components import PowerPort
-from .devices import Device, Module
+from .devices import Device
+from .modules import Module
 from .power import PowerFeed
 from .power import PowerFeed
 
 
 __all__ = (
 __all__ = (
@@ -144,6 +146,10 @@ class RackType(RackBase):
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
+    rack_count = CounterCacheField(
+        to_model='dcim.Rack',
+        to_field='rack_type'
+    )
 
 
     clone_fields = (
     clone_fields = (
         'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
         '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')
         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.
     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.
     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()]
         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).
         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
         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
         :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
             height of the elevation
             height of the elevation
         :param legend_width: Width of the unit legend, in pixels
         :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 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 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(
         elevation = RackElevationSVG(
             self,
             self,

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

@@ -109,10 +109,10 @@ class DeviceTypeTable(PrimaryModelTable):
         template_code=WEIGHT,
         template_code=WEIGHT,
         order_by=('_abs_weight', 'weight_unit')
         order_by=('_abs_weight', 'weight_unit')
     )
     )
-    instance_count = columns.LinkedCountColumn(
+    device_count = columns.LinkedCountColumn(
         viewname='dcim:device_list',
         viewname='dcim:device_list',
         url_params={'device_type_id': 'pk'},
         url_params={'device_type_id': 'pk'},
-        verbose_name=_('Instances')
+        verbose_name=_('Device Count'),
     )
     )
     console_port_template_count = tables.Column(
     console_port_template_count = tables.Column(
         verbose_name=_('Console Ports')
         verbose_name=_('Console Ports')
@@ -150,10 +150,10 @@ class DeviceTypeTable(PrimaryModelTable):
         fields = (
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
             'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
             'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
             '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 = (
         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')
         order_by=('_abs_weight', 'weight_unit')
     )
     )
     attributes = columns.DictColumn()
     attributes = columns.DictColumn()
-    instance_count = columns.LinkedCountColumn(
+    module_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
         url_params={'module_type_id': 'pk'},
-        verbose_name=_('Instances')
+        verbose_name=_('Module Count'),
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:moduletype_list'
         url_name='dcim:moduletype_list'
@@ -69,10 +69,10 @@ class ModuleTypeTable(PrimaryModelTable):
         model = ModuleType
         model = ModuleType
         fields = (
         fields = (
             'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
             '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 = (
         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,
         template_code=WEIGHT,
         order_by=('_abs_max_weight', 'weight_unit')
         order_by=('_abs_max_weight', 'weight_unit')
     )
     )
-    instance_count = columns.LinkedCountColumn(
+    rack_count = columns.LinkedCountColumn(
         viewname='dcim:rack_list',
         viewname='dcim:rack_list',
         url_params={'rack_type_id': 'pk'},
         url_params={'rack_type_id': 'pk'},
-        verbose_name=_('Instances')
+        verbose_name=_('Rack Count'),
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
         url_name='dcim:rack_list'
@@ -90,10 +90,10 @@ class RackTypeTable(PrimaryModelTable):
         fields = (
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
             'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
             'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
             '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 = (
         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):
 class RackTypeTest(APIViewTestCases.APIViewTestCase):
     model = RackType
     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 = {
     bulk_update_data = {
         'description': 'new description',
         'description': 'new description',
     }
     }
@@ -610,7 +610,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 
 
 class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
 class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
     model = ModuleType
     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 = {
     bulk_update_data = {
         'part_number': 'ABC123',
         '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)
 @register_model_view(RackType, 'list', path='', detail=False)
 class RackTypeListView(generic.ObjectListView):
 class RackTypeListView(generic.ObjectListView):
-    queryset = RackType.objects.annotate(
-        instance_count=count_related(Rack, 'rack_type')
-    )
+    queryset = RackType.objects.all()
     filterset = filtersets.RackTypeFilterSet
     filterset = filtersets.RackTypeFilterSet
     filterset_form = forms.RackTypeFilterForm
     filterset_form = forms.RackTypeFilterForm
     table = tables.RackTypeTable
     table = tables.RackTypeTable
@@ -1298,9 +1296,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
 
 
 @register_model_view(DeviceType, 'list', path='', detail=False)
 @register_model_view(DeviceType, 'list', path='', detail=False)
 class DeviceTypeListView(generic.ObjectListView):
 class DeviceTypeListView(generic.ObjectListView):
-    queryset = DeviceType.objects.annotate(
-        instance_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     filterset = filtersets.DeviceTypeFilterSet
     filterset = filtersets.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
@@ -1531,9 +1527,7 @@ class DeviceTypeImportView(generic.BulkImportView):
 
 
 @register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False)
 @register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False)
 class DeviceTypeBulkEditView(generic.BulkEditView):
 class DeviceTypeBulkEditView(generic.BulkEditView):
-    queryset = DeviceType.objects.annotate(
-        instance_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     filterset = filtersets.DeviceTypeFilterSet
     filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
     form = forms.DeviceTypeBulkEditForm
     form = forms.DeviceTypeBulkEditForm
@@ -1548,9 +1542,7 @@ class DeviceTypeBulkRenameView(generic.BulkRenameView):
 
 
 @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
 @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
-    queryset = DeviceType.objects.annotate(
-        instance_count=count_related(Device, 'device_type')
-    )
+    queryset = DeviceType.objects.all()
     filterset = filtersets.DeviceTypeFilterSet
     filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
 
 
@@ -1652,9 +1644,7 @@ class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
 
 
 @register_model_view(ModuleType, 'list', path='', detail=False)
 @register_model_view(ModuleType, 'list', path='', detail=False)
 class ModuleTypeListView(generic.ObjectListView):
 class ModuleTypeListView(generic.ObjectListView):
-    queryset = ModuleType.objects.annotate(
-        instance_count=count_related(Module, 'module_type')
-    )
+    queryset = ModuleType.objects.all()
     filterset = filtersets.ModuleTypeFilterSet
     filterset = filtersets.ModuleTypeFilterSet
     filterset_form = forms.ModuleTypeFilterForm
     filterset_form = forms.ModuleTypeFilterForm
     table = tables.ModuleTypeTable
     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 django.urls import reverse
 
 
 from dcim.models import *
 from dcim.models import *
+from utilities.counters import connect_counters
 from utilities.testing.base import TestCase
 from utilities.testing.base import TestCase
 from utilities.testing.utils import create_test_device
 from utilities.testing.utils import create_test_device
 
 
 
 
 class CountersTest(TestCase):
 class CountersTest(TestCase):
     """
     """
-    Validate the operation of dict_to_filter_params().
+    Validate the operation of the CounterCacheField (tracking counters).
     """
     """
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -24,7 +25,7 @@ class CountersTest(TestCase):
 
 
     def test_interface_count_creation(self):
     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()
         device1, device2 = Device.objects.all()
         self.assertEqual(device1.interface_count, 2)
         self.assertEqual(device1.interface_count, 2)
@@ -51,7 +52,7 @@ class CountersTest(TestCase):
 
 
     def test_interface_count_deletion(self):
     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()
         device1, device2 = Device.objects.all()
         self.assertEqual(device1.interface_count, 2)
         self.assertEqual(device1.interface_count, 2)
@@ -66,7 +67,7 @@ class CountersTest(TestCase):
 
 
     def test_interface_count_move(self):
     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()
         device1, device2 = Device.objects.all()
         self.assertEqual(device1.interface_count, 2)
         self.assertEqual(device1.interface_count, 2)
@@ -102,3 +103,35 @@ class CountersTest(TestCase):
         self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
         self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
         device1.refresh_from_db()
         device1.refresh_from_db()
         self.assertEqual(device1.inventory_item_count, 0)
         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')