Skip to content

Commit d14e741

Browse files
committed
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
1 parent 01cbdbb commit d14e741

File tree

17 files changed

+202
-57
lines changed

17 files changed

+202
-57
lines changed

netbox/dcim/api/serializers_/devicetypes.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from dcim.choices import *
77
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
8-
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
8+
from netbox.api.fields import AttributesField, ChoiceField
99
from netbox.api.serializers import PrimaryModelSerializer
1010
from netbox.choices import *
1111
from .manufacturers import ManufacturerSerializer
@@ -45,9 +45,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
4545
device_bay_template_count = serializers.IntegerField(read_only=True)
4646
module_bay_template_count = serializers.IntegerField(read_only=True)
4747
inventory_item_template_count = serializers.IntegerField(read_only=True)
48-
49-
# Related object counts
50-
device_count = RelatedObjectCountField('instances')
48+
device_count = serializers.IntegerField(read_only=True)
5149

5250
class Meta:
5351
model = DeviceType
@@ -100,12 +98,13 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
10098
required=False,
10199
allow_null=True
102100
)
101+
module_count = serializers.IntegerField(read_only=True)
103102

104103
class Meta:
105104
model = ModuleType
106105
fields = [
107106
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
108107
'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
109-
'created', 'last_updated',
108+
'created', 'last_updated', 'module_count',
110109
]
111-
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')
110+
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')

netbox/dcim/api/serializers_/racks.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,18 @@ class RackBaseSerializer(PrimaryModelSerializer):
6262

6363

6464
class RackTypeSerializer(RackBaseSerializer):
65-
manufacturer = ManufacturerSerializer(
66-
nested=True
67-
)
65+
manufacturer = ManufacturerSerializer(nested=True)
66+
rack_count = serializers.IntegerField(read_only=True)
6867

6968
class Meta:
7069
model = RackType
7170
fields = [
7271
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
7372
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
7473
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
75-
'tags', 'custom_fields', 'created', 'last_updated',
74+
'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
7675
]
77-
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
76+
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
7877

7978

8079
class RackSerializer(RackBaseSerializer):

netbox/dcim/apps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def ready(self):
1111
from netbox.models.features import register_models
1212
from utilities.counters import connect_counters
1313
from . import signals, search # noqa: F401
14-
from .models import CableTermination, Device, DeviceType, VirtualChassis
14+
from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
1515

1616
# Register models
1717
register_models(*self.get_models())
@@ -31,4 +31,4 @@ def ready(self):
3131
})
3232

3333
# Register counters
34-
connect_counters(Device, DeviceType, VirtualChassis)
34+
connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis)

netbox/dcim/filtersets.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,9 @@ class Meta:
317317
fields = (
318318
'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
319319
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
320+
321+
# Counters
322+
'rack_count',
320323
)
321324

322325
def search(self, queryset, name, value):
@@ -627,6 +630,7 @@ class Meta:
627630
'device_bay_template_count',
628631
'module_bay_template_count',
629632
'inventory_item_template_count',
633+
'device_count',
630634
)
631635

632636
def search(self, queryset, name, value):
@@ -747,7 +751,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
747751

748752
class Meta:
749753
model = ModuleType
750-
fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
754+
fields = (
755+
'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
756+
757+
# Counters
758+
'module_count',
759+
)
751760

752761
def search(self, queryset, name, value):
753762
if not value.strip():

netbox/dcim/forms/filtersets.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
317317
model = RackType
318318
fieldsets = (
319319
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
320-
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
320+
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
321321
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
322322
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
323323
)
@@ -327,6 +327,11 @@ class RackTypeFilterForm(RackBaseFilterForm):
327327
required=False,
328328
label=_('Manufacturer')
329329
)
330+
rack_count = forms.IntegerField(
331+
label=_('Rack count'),
332+
required=False,
333+
min_value=0,
334+
)
330335
tag = TagFilterField(model)
331336

332337

@@ -498,7 +503,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
498503
fieldsets = (
499504
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
500505
FieldSet(
501-
'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware')
506+
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
507+
'subdevice_role', 'airflow', name=_('Hardware')
502508
),
503509
FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
504510
FieldSet(
@@ -522,6 +528,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
522528
label=_('Part number'),
523529
required=False
524530
)
531+
device_count = forms.IntegerField(
532+
label=_('Device count'),
533+
required=False,
534+
min_value=0,
535+
)
525536
subdevice_role = forms.MultipleChoiceField(
526537
label=_('Subdevice role'),
527538
choices=add_blank_choice(SubdeviceRoleChoices),
@@ -633,7 +644,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
633644
model = ModuleType
634645
fieldsets = (
635646
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
636-
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
647+
FieldSet(
648+
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
649+
'airflow', name=_('Hardware')
650+
),
637651
FieldSet(
638652
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
639653
'pass_through_ports', name=_('Components')
@@ -655,6 +669,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
655669
label=_('Part number'),
656670
required=False
657671
)
672+
module_count = forms.IntegerField(
673+
label=_('Module count'),
674+
required=False,
675+
min_value=0,
676+
)
658677
console_ports = forms.NullBooleanField(
659678
required=False,
660679
label=_('Has console ports'),

netbox/dcim/graphql/filters.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import strawberry
55
import strawberry_django
66
from strawberry.scalars import ID
7-
from strawberry_django import FilterLookup
7+
from strawberry_django import ComparisonFilterLookup, FilterLookup
88

99
from core.graphql.filter_mixins import ChangeLogFilterMixin
1010
from dcim import models
@@ -328,6 +328,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
328328
)
329329
default_platform_id: ID | None = strawberry_django.filter_field()
330330
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
331+
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
332+
strawberry_django.filter_field()
333+
)
331334
u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
332335
strawberry_django.filter_field()
333336
)
@@ -385,6 +388,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
385388
device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
386389
module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
387390
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
391+
device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
388392

389393

390394
@strawberry_django.filter_type(models.FrontPort, lookups=True)
@@ -685,6 +689,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
685689
profile_id: ID | None = strawberry_django.filter_field()
686690
model: FilterLookup[str] | None = strawberry_django.filter_field()
687691
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
692+
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
693+
strawberry_django.filter_field()
694+
)
688695
airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
689696
strawberry_django.filter_field()
690697
)
@@ -718,6 +725,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
718725
inventory_item_templates: (
719726
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
720727
) = strawberry_django.filter_field()
728+
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
721729

722730

723731
@strawberry_django.filter_type(models.Platform, lookups=True)
@@ -846,6 +854,8 @@ class RackTypeFilter(RackBaseFilterMixin):
846854
manufacturer_id: ID | None = strawberry_django.filter_field()
847855
model: FilterLookup[str] | None = strawberry_django.filter_field()
848856
slug: FilterLookup[str] | None = strawberry_django.filter_field()
857+
racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
858+
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
849859

850860

851861
@strawberry_django.filter_type(models.Rack, lookups=True)

netbox/dcim/graphql/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ class DeviceTypeType(PrimaryObjectType):
358358
device_bay_template_count: BigInt
359359
module_bay_template_count: BigInt
360360
inventory_item_template_count: BigInt
361+
device_count: BigInt
361362
front_image: strawberry_django.fields.types.DjangoImageType | None
362363
rear_image: strawberry_django.fields.types.DjangoImageType | None
363364
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -605,6 +606,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
605606
pagination=True
606607
)
607608
class ModuleTypeType(PrimaryObjectType):
609+
module_count: BigInt
608610
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
609611
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
610612

@@ -709,6 +711,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
709711
pagination=True
710712
)
711713
class RackTypeType(PrimaryObjectType):
714+
rack_count: BigInt
712715
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
713716

714717

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import utilities.fields
2+
from django.db import migrations
3+
from django.db.models import Count, OuterRef, Subquery
4+
5+
6+
def _populate_count_for_type(
7+
apps, schema_editor, app_name: str, model_name: str, target_field: str, related_name: str = 'instances'
8+
):
9+
"""
10+
Update a CounterCache field on the specified model by annotating the count of related instances.
11+
"""
12+
Model = apps.get_model(app_name, model_name)
13+
db_alias = schema_editor.connection.alias
14+
15+
count_subquery = (
16+
Model.objects.using(db_alias)
17+
.filter(pk=OuterRef('pk'))
18+
.annotate(_instance_count=Count(related_name))
19+
.values('_instance_count')
20+
)
21+
Model.objects.using(db_alias).update(**{target_field: Subquery(count_subquery)})
22+
23+
24+
def populate_device_type_device_count(apps, schema_editor):
25+
_populate_count_for_type(apps, schema_editor, 'dcim', 'DeviceType', 'device_count')
26+
27+
28+
def populate_module_type_module_count(apps, schema_editor):
29+
_populate_count_for_type(apps, schema_editor, 'dcim', 'ModuleType', 'module_count')
30+
31+
32+
def populate_rack_type_rack_count(apps, schema_editor):
33+
_populate_count_for_type(apps, schema_editor, 'dcim', 'RackType', 'rack_count', related_name='racks')
34+
35+
36+
class Migration(migrations.Migration):
37+
dependencies = [
38+
('dcim', '0217_owner'),
39+
]
40+
41+
operations = [
42+
migrations.AddField(
43+
model_name='devicetype',
44+
name='device_count',
45+
field=utilities.fields.CounterCacheField(
46+
default=0, editable=False, to_field='device_type', to_model='dcim.Device'
47+
),
48+
),
49+
migrations.RunPython(populate_device_type_device_count, migrations.RunPython.noop),
50+
migrations.AddField(
51+
model_name='moduletype',
52+
name='module_count',
53+
field=utilities.fields.CounterCacheField(
54+
default=0, editable=False, to_field='module_type', to_model='dcim.Module'
55+
),
56+
),
57+
migrations.RunPython(populate_module_type_module_count, migrations.RunPython.noop),
58+
migrations.AddField(
59+
model_name='racktype',
60+
name='rack_count',
61+
field=utilities.fields.CounterCacheField(
62+
default=0, editable=False, to_field='rack_type', to_model='dcim.Rack'
63+
),
64+
),
65+
migrations.RunPython(populate_rack_type_rack_count, migrations.RunPython.noop),
66+
]

netbox/dcim/models/devices.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
185185
to_model='dcim.InventoryItemTemplate',
186186
to_field='device_type'
187187
)
188+
device_count = CounterCacheField(
189+
to_model='dcim.Device',
190+
to_field='device_type'
191+
)
188192

189193
clone_fields = (
190194
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',

netbox/dcim/models/modules.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
from netbox.models import PrimaryModel
1414
from netbox.models.features import ImageAttachmentsMixin
1515
from netbox.models.mixins import WeightMixin
16+
from utilities.fields import CounterCacheField
1617
from utilities.jsonschema import validate_schema
1718
from utilities.string import title
19+
from utilities.tracking import TrackingModelMixin
1820
from .device_components import *
1921

2022
__all__ = (
@@ -92,6 +94,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
9294
null=True,
9395
verbose_name=_('attributes')
9496
)
97+
module_count = CounterCacheField(
98+
to_model='dcim.Module',
99+
to_field='module_type'
100+
)
95101

96102
clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
97103
prerequisite_models = (
@@ -186,7 +192,7 @@ def to_yaml(self):
186192
return yaml.dump(dict(data), sort_keys=False)
187193

188194

189-
class Module(PrimaryModel, ConfigContextModel):
195+
class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
190196
"""
191197
A Module represents a field-installable component within a Device which may itself hold multiple device components
192198
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.

0 commit comments

Comments
 (0)