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.choices import *
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField from netbox.api.fields import AttributesField, ChoiceField
from netbox.api.serializers import PrimaryModelSerializer from netbox.api.serializers import PrimaryModelSerializer
from netbox.choices import * from netbox.choices import *
from .manufacturers import ManufacturerSerializer from .manufacturers import ManufacturerSerializer
@@ -45,9 +45,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
device_bay_template_count = serializers.IntegerField(read_only=True) device_bay_template_count = serializers.IntegerField(read_only=True)
module_bay_template_count = serializers.IntegerField(read_only=True) module_bay_template_count = serializers.IntegerField(read_only=True)
inventory_item_template_count = serializers.IntegerField(read_only=True) inventory_item_template_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
# Related object counts
device_count = RelatedObjectCountField('instances')
class Meta: class Meta:
model = DeviceType model = DeviceType
@@ -100,12 +98,13 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
required=False, required=False,
allow_null=True allow_null=True
) )
module_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields', 'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'created', 'last_updated', 'module_count',
] ]
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description') brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')

View File

@@ -62,9 +62,8 @@ class RackBaseSerializer(PrimaryModelSerializer):
class RackTypeSerializer(RackBaseSerializer): class RackTypeSerializer(RackBaseSerializer):
manufacturer = ManufacturerSerializer( manufacturer = ManufacturerSerializer(nested=True)
nested=True rack_count = serializers.IntegerField(read_only=True)
)
class Meta: class Meta:
model = RackType model = RackType
@@ -72,9 +71,9 @@ class RackTypeSerializer(RackBaseSerializer):
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor', 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments', 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
] ]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description') brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
class RackSerializer(RackBaseSerializer): class RackSerializer(RackBaseSerializer):

View File

@@ -11,7 +11,7 @@ class DCIMConfig(AppConfig):
from netbox.models.features import register_models from netbox.models.features import register_models
from utilities.counters import connect_counters from utilities.counters import connect_counters
from . import signals, search # noqa: F401 from . import signals, search # noqa: F401
from .models import CableTermination, Device, DeviceType, VirtualChassis from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())
@@ -31,4 +31,4 @@ class DCIMConfig(AppConfig):
}) })
# Register counters # Register counters
connect_counters(Device, DeviceType, VirtualChassis) connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis)

View File

@@ -317,6 +317,9 @@ class RackTypeFilterSet(PrimaryModelFilterSet):
fields = ( fields = (
'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
# Counters
'rack_count',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
@@ -627,6 +630,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
'device_bay_template_count', 'device_bay_template_count',
'module_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count', 'inventory_item_template_count',
'device_count',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
@@ -747,7 +751,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description') fields = (
'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
# Counters
'module_count',
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@@ -317,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
model = RackType model = RackType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')), FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
) )
@@ -327,6 +327,11 @@ class RackTypeFilterForm(RackBaseFilterForm):
required=False, required=False,
label=_('Manufacturer') label=_('Manufacturer')
) )
rack_count = forms.IntegerField(
label=_('Rack count'),
required=False,
min_value=0,
)
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -498,7 +503,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet( FieldSet(
'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware') 'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
'subdevice_role', 'airflow', name=_('Hardware')
), ),
FieldSet('has_front_image', 'has_rear_image', name=_('Images')), FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
FieldSet( FieldSet(
@@ -522,6 +528,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
label=_('Part number'), label=_('Part number'),
required=False required=False
) )
device_count = forms.IntegerField(
label=_('Device count'),
required=False,
min_value=0,
)
subdevice_role = forms.MultipleChoiceField( subdevice_role = forms.MultipleChoiceField(
label=_('Subdevice role'), label=_('Subdevice role'),
choices=add_blank_choice(SubdeviceRoleChoices), choices=add_blank_choice(SubdeviceRoleChoices),
@@ -633,7 +644,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware')
),
FieldSet( FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', name=_('Components') 'pass_through_ports', name=_('Components')
@@ -655,6 +669,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
label=_('Part number'), label=_('Part number'),
required=False required=False
) )
module_count = forms.IntegerField(
label=_('Module count'),
required=False,
min_value=0,
)
console_ports = forms.NullBooleanField( console_ports = forms.NullBooleanField(
required=False, required=False,
label=_('Has console ports'), label=_('Has console ports'),

View File

@@ -4,7 +4,7 @@ from django.db.models import Q
import strawberry import strawberry
import strawberry_django import strawberry_django
from strawberry.scalars import ID from strawberry.scalars import ID
from strawberry_django import FilterLookup from strawberry_django import ComparisonFilterLookup, FilterLookup
from core.graphql.filter_mixins import ChangeLogFilterMixin from core.graphql.filter_mixins import ChangeLogFilterMixin
from dcim import models from dcim import models
@@ -328,6 +328,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
) )
default_platform_id: ID | None = strawberry_django.filter_field() default_platform_id: ID | None = strawberry_django.filter_field()
part_number: FilterLookup[str] | None = strawberry_django.filter_field() part_number: FilterLookup[str] | None = strawberry_django.filter_field()
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -385,6 +388,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.FrontPort, lookups=True) @strawberry_django.filter_type(models.FrontPort, lookups=True)
@@ -685,6 +689,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
profile_id: ID | None = strawberry_django.filter_field() profile_id: ID | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field() model: FilterLookup[str] | None = strawberry_django.filter_field()
part_number: FilterLookup[str] | None = strawberry_django.filter_field() part_number: FilterLookup[str] | None = strawberry_django.filter_field()
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -718,6 +725,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
inventory_item_templates: ( inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field() ) = strawberry_django.filter_field()
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Platform, lookups=True) @strawberry_django.filter_type(models.Platform, lookups=True)
@@ -846,6 +854,8 @@ class RackTypeFilter(RackBaseFilterMixin):
manufacturer_id: ID | None = strawberry_django.filter_field() manufacturer_id: ID | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field() model: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field()
racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Rack, lookups=True) @strawberry_django.filter_type(models.Rack, lookups=True)

View File

@@ -358,6 +358,7 @@ class DeviceTypeType(PrimaryObjectType):
device_bay_template_count: BigInt device_bay_template_count: BigInt
module_bay_template_count: BigInt module_bay_template_count: BigInt
inventory_item_template_count: BigInt inventory_item_template_count: BigInt
device_count: BigInt
front_image: strawberry_django.fields.types.DjangoImageType | None front_image: strawberry_django.fields.types.DjangoImageType | None
rear_image: strawberry_django.fields.types.DjangoImageType | None rear_image: strawberry_django.fields.types.DjangoImageType | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -605,6 +606,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
pagination=True pagination=True
) )
class ModuleTypeType(PrimaryObjectType): class ModuleTypeType(PrimaryObjectType):
module_count: BigInt
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -709,6 +711,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
pagination=True pagination=True
) )
class RackTypeType(PrimaryObjectType): class RackTypeType(PrimaryObjectType):
rack_count: BigInt
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]

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_model='dcim.InventoryItemTemplate',
to_field='device_type' to_field='device_type'
) )
device_count = CounterCacheField(
to_model='dcim.Device',
to_field='device_type'
)
clone_fields = ( clone_fields = (
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',

View File

@@ -13,8 +13,10 @@ from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin from netbox.models.features import ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin from netbox.models.mixins import WeightMixin
from utilities.fields import CounterCacheField
from utilities.jsonschema import validate_schema from utilities.jsonschema import validate_schema
from utilities.string import title from utilities.string import title
from utilities.tracking import TrackingModelMixin
from .device_components import * from .device_components import *
__all__ = ( __all__ = (
@@ -92,6 +94,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
null=True, null=True,
verbose_name=_('attributes') verbose_name=_('attributes')
) )
module_count = CounterCacheField(
to_model='dcim.Module',
to_field='module_type'
)
clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
prerequisite_models = ( prerequisite_models = (
@@ -186,7 +192,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
return yaml.dump(dict(data), sort_keys=False) return yaml.dump(dict(data), sort_keys=False)
class Module(PrimaryModel, ConfigContextModel): class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
""" """
A Module represents a field-installable component within a Device which may itself hold multiple device components A Module represents a field-installable component within a Device which may itself hold multiple device components
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.

View File

@@ -19,9 +19,11 @@ from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.conversion import to_grams from utilities.conversion import to_grams
from utilities.data import array_to_string, drange from utilities.data import array_to_string, drange
from utilities.fields import ColorField from utilities.fields import ColorField, CounterCacheField
from utilities.tracking import TrackingModelMixin
from .device_components import PowerPort from .device_components import PowerPort
from .devices import Device, Module from .devices import Device
from .modules import Module
from .power import PowerFeed from .power import PowerFeed
__all__ = ( __all__ = (
@@ -144,6 +146,10 @@ class RackType(RackBase):
max_length=100, max_length=100,
unique=True unique=True
) )
rack_count = CounterCacheField(
to_model='dcim.Rack',
to_field='rack_type'
)
clone_fields = ( clone_fields = (
'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
@@ -234,7 +240,7 @@ class RackRole(OrganizationalModel):
verbose_name_plural = _('rack roles') verbose_name_plural = _('rack roles')
class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location. Each Rack is assigned to a Site and (optionally) a Location.
@@ -509,7 +515,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
return [u for u in elevation.values()] return [u for u in elevation.values()]
def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False): def get_available_units(self, u_height=1.0, rack_face=None, exclude=None, ignore_excluded_devices=False):
""" """
Return a list of units within the rack available to accommodate a device of a given U height (default 1). Return a list of units within the rack available to accommodate a device of a given U height (default 1).
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
@@ -581,9 +587,10 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation height of the elevation
:param legend_width: Width of the unit legend, in pixels :param legend_width: Width of the unit legend, in pixels
:param margin_width: Width of the rigth-hand margin, in pixels :param margin_width: Width of the right-hand margin, in pixels
:param include_images: Embed front/rear device images where available :param include_images: Embed front/rear device images where available
:param base_url: Base URL for links and images. If none, URLs will be relative. :param base_url: Base URL for links and images. If none, URLs will be relative.
:param highlight_params: Dictionary of parameters to be passed to the RackElevationSVG.render_highlight() method
""" """
elevation = RackElevationSVG( elevation = RackElevationSVG(
self, self,

View File

@@ -109,10 +109,10 @@ class DeviceTypeTable(PrimaryModelTable):
template_code=WEIGHT, template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit') order_by=('_abs_weight', 'weight_unit')
) )
instance_count = columns.LinkedCountColumn( device_count = columns.LinkedCountColumn(
viewname='dcim:device_list', viewname='dcim:device_list',
url_params={'device_type_id': 'pk'}, url_params={'device_type_id': 'pk'},
verbose_name=_('Instances') verbose_name=_('Device Count'),
) )
console_port_template_count = tables.Column( console_port_template_count = tables.Column(
verbose_name=_('Console Ports') verbose_name=_('Console Ports')
@@ -150,10 +150,10 @@ class DeviceTypeTable(PrimaryModelTable):
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'device_count',
) )

View File

@@ -56,10 +56,10 @@ class ModuleTypeTable(PrimaryModelTable):
order_by=('_abs_weight', 'weight_unit') order_by=('_abs_weight', 'weight_unit')
) )
attributes = columns.DictColumn() attributes = columns.DictColumn()
instance_count = columns.LinkedCountColumn( module_count = columns.LinkedCountColumn(
viewname='dcim:module_list', viewname='dcim:module_list',
url_params={'module_type_id': 'pk'}, url_params={'module_type_id': 'pk'},
verbose_name=_('Instances') verbose_name=_('Module Count'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:moduletype_list' url_name='dcim:moduletype_list'
@@ -69,10 +69,10 @@ class ModuleTypeTable(PrimaryModelTable):
model = ModuleType model = ModuleType
fields = ( fields = (
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
'attributes', 'comments', 'tags', 'created', 'last_updated', 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'profile', 'manufacturer', 'part_number', 'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count',
) )

View File

@@ -76,10 +76,10 @@ class RackTypeTable(PrimaryModelTable):
template_code=WEIGHT, template_code=WEIGHT,
order_by=('_abs_max_weight', 'weight_unit') order_by=('_abs_max_weight', 'weight_unit')
) )
instance_count = columns.LinkedCountColumn( rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list', viewname='dcim:rack_list',
url_params={'rack_type_id': 'pk'}, url_params={'rack_type_id': 'pk'},
verbose_name=_('Instances') verbose_name=_('Rack Count'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:rack_list' url_name='dcim:rack_list'
@@ -90,10 +90,10 @@ class RackTypeTable(PrimaryModelTable):
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
'comments', 'instance_count', 'tags', 'created', 'last_updated', 'comments', 'rack_count', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count', 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'rack_count',
) )

View File

@@ -317,7 +317,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTypeTest(APIViewTestCases.APIViewTestCase): class RackTypeTest(APIViewTestCases.APIViewTestCase):
model = RackType model = RackType
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'new description', 'description': 'new description',
} }
@@ -610,7 +610,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ModuleTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
model = ModuleType model = ModuleType
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url'] brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
bulk_update_data = { bulk_update_data = {
'part_number': 'ABC123', 'part_number': 'ABC123',
} }

View File

@@ -856,9 +856,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
@register_model_view(RackType, 'list', path='', detail=False) @register_model_view(RackType, 'list', path='', detail=False)
class RackTypeListView(generic.ObjectListView): class RackTypeListView(generic.ObjectListView):
queryset = RackType.objects.annotate( queryset = RackType.objects.all()
instance_count=count_related(Rack, 'rack_type')
)
filterset = filtersets.RackTypeFilterSet filterset = filtersets.RackTypeFilterSet
filterset_form = forms.RackTypeFilterForm filterset_form = forms.RackTypeFilterForm
table = tables.RackTypeTable table = tables.RackTypeTable
@@ -1298,9 +1296,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
@register_model_view(DeviceType, 'list', path='', detail=False) @register_model_view(DeviceType, 'list', path='', detail=False)
class DeviceTypeListView(generic.ObjectListView): class DeviceTypeListView(generic.ObjectListView):
queryset = DeviceType.objects.annotate( queryset = DeviceType.objects.all()
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet filterset = filtersets.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm filterset_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@@ -1531,9 +1527,7 @@ class DeviceTypeImportView(generic.BulkImportView):
@register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False) @register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False)
class DeviceTypeBulkEditView(generic.BulkEditView): class DeviceTypeBulkEditView(generic.BulkEditView):
queryset = DeviceType.objects.annotate( queryset = DeviceType.objects.all()
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet filterset = filtersets.DeviceTypeFilterSet
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
form = forms.DeviceTypeBulkEditForm form = forms.DeviceTypeBulkEditForm
@@ -1548,9 +1542,7 @@ class DeviceTypeBulkRenameView(generic.BulkRenameView):
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False) @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
class DeviceTypeBulkDeleteView(generic.BulkDeleteView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.annotate( queryset = DeviceType.objects.all()
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet filterset = filtersets.DeviceTypeFilterSet
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@@ -1652,9 +1644,7 @@ class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
@register_model_view(ModuleType, 'list', path='', detail=False) @register_model_view(ModuleType, 'list', path='', detail=False)
class ModuleTypeListView(generic.ObjectListView): class ModuleTypeListView(generic.ObjectListView):
queryset = ModuleType.objects.annotate( queryset = ModuleType.objects.all()
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet filterset = filtersets.ModuleTypeFilterSet
filterset_form = forms.ModuleTypeFilterForm filterset_form = forms.ModuleTypeFilterForm
table = tables.ModuleTypeTable table = tables.ModuleTypeTable

View File

@@ -2,13 +2,14 @@ from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from dcim.models import * from dcim.models import *
from utilities.counters import connect_counters
from utilities.testing.base import TestCase from utilities.testing.base import TestCase
from utilities.testing.utils import create_test_device from utilities.testing.utils import create_test_device
class CountersTest(TestCase): class CountersTest(TestCase):
""" """
Validate the operation of dict_to_filter_params(). Validate the operation of the CounterCacheField (tracking counters).
""" """
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -24,7 +25,7 @@ class CountersTest(TestCase):
def test_interface_count_creation(self): def test_interface_count_creation(self):
""" """
When a tracked object (Interface) is added the tracking counter should be updated. When a tracked object (Interface) is added, the tracking counter should be updated.
""" """
device1, device2 = Device.objects.all() device1, device2 = Device.objects.all()
self.assertEqual(device1.interface_count, 2) self.assertEqual(device1.interface_count, 2)
@@ -51,7 +52,7 @@ class CountersTest(TestCase):
def test_interface_count_deletion(self): def test_interface_count_deletion(self):
""" """
When a tracked object (Interface) is deleted the tracking counter should be updated. When a tracked object (Interface) is deleted, the tracking counter should be updated.
""" """
device1, device2 = Device.objects.all() device1, device2 = Device.objects.all()
self.assertEqual(device1.interface_count, 2) self.assertEqual(device1.interface_count, 2)
@@ -66,7 +67,7 @@ class CountersTest(TestCase):
def test_interface_count_move(self): def test_interface_count_move(self):
""" """
When a tracked object (Interface) is moved the tracking counter should be updated. When a tracked object (Interface) is moved, the tracking counter should be updated.
""" """
device1, device2 = Device.objects.all() device1, device2 = Device.objects.all()
self.assertEqual(device1.interface_count, 2) self.assertEqual(device1.interface_count, 2)
@@ -102,3 +103,35 @@ class CountersTest(TestCase):
self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data) self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
device1.refresh_from_db() device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 0) self.assertEqual(device1.inventory_item_count, 0)
def test_signal_connections_are_idempotent_per_sender(self):
"""
Calling connect_counters() again must not register duplicate receivers.
Creating a device after repeated "connect_counters" should still yield +1.
"""
connect_counters(DeviceType, VirtualChassis)
vc, _ = VirtualChassis.objects.get_or_create(name='Virtual Chassis 1')
device1, device2 = Device.objects.all()
self.assertEqual(device1.device_type.device_count, 2)
self.assertEqual(vc.member_count, 0)
# Call again (should be a no-op for sender registrations)
connect_counters(DeviceType, VirtualChassis)
# Create one new device
device3 = create_test_device('Device 3')
device3.virtual_chassis = vc
device3.save()
# Ensure counter incremented correctly
device1.refresh_from_db()
vc.refresh_from_db()
self.assertEqual(device1.device_type.device_count, 3, 'device_count should increment exactly once')
self.assertEqual(vc.member_count, 1, 'member_count should increment exactly once')
# Clean up and ensure counter decremented correctly
device3.delete()
device1.refresh_from_db()
vc.refresh_from_db()
self.assertEqual(device1.device_type.device_count, 2, 'device_count should decrement exactly once')
self.assertEqual(vc.member_count, 0, 'member_count should decrement exactly once')