From d14e74179f3f08d2163608d6e30a317a196665e4 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Thu, 13 Nov 2025 17:17:39 +0100 Subject: [PATCH] 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 --- netbox/dcim/api/serializers_/devicetypes.py | 11 ++-- netbox/dcim/api/serializers_/racks.py | 9 ++- netbox/dcim/apps.py | 4 +- netbox/dcim/filtersets.py | 11 +++- netbox/dcim/forms/filtersets.py | 25 ++++++- netbox/dcim/graphql/filters.py | 12 +++- netbox/dcim/graphql/types.py | 3 + .../0218_devicetype_device_count.py | 66 +++++++++++++++++++ netbox/dcim/models/devices.py | 4 ++ netbox/dcim/models/modules.py | 8 ++- netbox/dcim/models/racks.py | 17 +++-- netbox/dcim/tables/devicetypes.py | 8 +-- netbox/dcim/tables/modules.py | 8 +-- netbox/dcim/tables/racks.py | 8 +-- netbox/dcim/tests/test_api.py | 4 +- netbox/dcim/views.py | 20 ++---- netbox/utilities/tests/test_counters.py | 41 ++++++++++-- 17 files changed, 202 insertions(+), 57 deletions(-) create mode 100644 netbox/dcim/migrations/0218_devicetype_device_count.py diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 59753847cc1..797d31d877c 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -5,7 +5,7 @@ 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') diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index ef06dc5aa8c..503f7bee375 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/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 Meta: '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): diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 9653d3b93a9..67ff17489b1 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -11,7 +11,7 @@ def ready(self): 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 @@ def ready(self): }) # Register counters - connect_counters(Device, DeviceType, VirtualChassis) + connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6c380c9f4c1..0fd7631ac5b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -317,6 +317,9 @@ class Meta: 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 Meta: '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(): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 157cb64f94a..1197002a5cf 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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'), diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index ccf4a2d987f..111902dd952 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -4,7 +4,7 @@ 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) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 09502554cad..13408dc90c4 100644 --- a/netbox/dcim/graphql/types.py +++ b/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')] diff --git a/netbox/dcim/migrations/0218_devicetype_device_count.py b/netbox/dcim/migrations/0218_devicetype_device_count.py new file mode 100644 index 00000000000..7a9a135b17f --- /dev/null +++ b/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), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 845cc68d20a..dc314616106 100644 --- a/netbox/dcim/models/devices.py +++ b/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', diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 4376f40aa47..4d26e32612d 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -13,8 +13,10 @@ 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 @@ def to_yaml(self): 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. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 02bce2019f6..d7afb78967f 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -19,9 +19,11 @@ 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 Meta: 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 @@ def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=N 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 @@ def get_elevation_svg( :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, diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 07afe5da2a4..979689b75c5 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/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 Meta(PrimaryModelTable.Meta): 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', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 78abfdd1922..92f5183b792 100644 --- a/netbox/dcim/tables/modules.py +++ b/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 Meta(PrimaryModelTable.Meta): 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', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1cc774f2250..ad329262fcb 100644 --- a/netbox/dcim/tables/racks.py +++ b/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 Meta(PrimaryModelTable.Meta): 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', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c70b546e3a9..938a625b08c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -317,7 +317,7 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9de9bd5132c..463d9817931 100644 --- a/netbox/dcim/views.py +++ b/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 @@ def prep_related_object_data(self, parent, data): @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 diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 668965e8ad3..6948923d7a8 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/netbox/utilities/tests/test_counters.py @@ -2,13 +2,14 @@ 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 @@ def setUpTestData(cls): 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 @@ def test_interface_count_creation(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() self.assertEqual(device1.interface_count, 2) @@ -66,7 +67,7 @@ def test_interface_count_deletion(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() self.assertEqual(device1.interface_count, 2) @@ -102,3 +103,35 @@ def test_mptt_child_delete(self): 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')