feat(dcim): Add device, module and rack count filters
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled

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
This commit is contained in:
Martin Hauser
2025-11-13 17:17:39 +01:00
committed by Jeremy Stretch
parent 01cbdbb968
commit cee2a5e0ed
17 changed files with 202 additions and 57 deletions

View File

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

View File

@@ -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):

View File

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

View File

@@ -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():

View File

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

View File

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

View File

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

View File

@@ -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),
]

View File

@@ -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',

View File

@@ -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.

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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',
}

View File

@@ -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

View File

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