From cee2a5e0ed17877cea9e5799c2a878b72ba052e2 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 59753847c..797d31d87 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/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') diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index ef06dc5aa..503f7bee3 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 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): diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 9653d3b93..67ff17489 100644 --- a/netbox/dcim/apps.py +++ b/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) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6c380c9f4..0fd7631ac 100644 --- a/netbox/dcim/filtersets.py +++ b/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(): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 157cb64f9..1197002a5 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 ccf4a2d98..111902dd9 100644 --- a/netbox/dcim/graphql/filters.py +++ b/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) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 09502554c..13408dc90 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 000000000..7a9a135b1 --- /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 845cc68d2..dc3146161 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 4376f40aa..4d26e3261 100644 --- a/netbox/dcim/models/modules.py +++ b/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. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 02bce2019..d7afb7896 100644 --- a/netbox/dcim/models/racks.py +++ b/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, diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 07afe5da2..979689b75 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 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', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 78abfdd19..92f5183b7 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 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', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1cc774f22..ad329262f 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 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', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c70b546e3..938a625b0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9de9bd513..463d98179 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 @@ 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 diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 668965e8a..6948923d7 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/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')