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')