Skip to content

Commit 50a63be

Browse files
committed
feat(dcim): Add device and module count filters
Introduces `device_count` and `module_count` filters to enable queries based on the existence and count of the associated device or module instances. Updates forms, filtersets, and GraphQL schema to support these filters, along with comprehensive tests for validation. Fixes #19523
1 parent 01cbdbb commit 50a63be

File tree

13 files changed

+154
-37
lines changed

13 files changed

+154
-37
lines changed

netbox/dcim/api/serializers_/devicetypes.py

Lines changed: 4 additions & 5 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
]
111110
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')

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, 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, VirtualChassis)

netbox/dcim/filtersets.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ class Meta:
627627
'device_bay_template_count',
628628
'module_bay_template_count',
629629
'inventory_item_template_count',
630+
'device_count',
630631
)
631632

632633
def search(self, queryset, name, value):
@@ -747,7 +748,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
747748

748749
class Meta:
749750
model = ModuleType
750-
fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
751+
fields = (
752+
'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
753+
754+
# Counters
755+
'module_count',
756+
)
751757

752758
def search(self, queryset, name, value):
753759
if not value.strip():

netbox/dcim/forms/filtersets.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
498498
fieldsets = (
499499
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
500500
FieldSet(
501-
'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware')
501+
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
502+
'subdevice_role', 'airflow', name=_('Hardware')
502503
),
503504
FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
504505
FieldSet(
@@ -522,6 +523,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
522523
label=_('Part number'),
523524
required=False
524525
)
526+
device_count = forms.IntegerField(
527+
label=_('Device count'),
528+
required=False,
529+
min_value=0,
530+
)
525531
subdevice_role = forms.MultipleChoiceField(
526532
label=_('Subdevice role'),
527533
choices=add_blank_choice(SubdeviceRoleChoices),
@@ -633,7 +639,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
633639
model = ModuleType
634640
fieldsets = (
635641
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
636-
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
642+
FieldSet(
643+
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
644+
'airflow', name=_('Hardware')
645+
),
637646
FieldSet(
638647
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
639648
'pass_through_ports', name=_('Components')
@@ -655,6 +664,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
655664
label=_('Part number'),
656665
required=False
657666
)
667+
module_count = forms.IntegerField(
668+
label=_('Module count'),
669+
required=False,
670+
min_value=0,
671+
)
658672
console_ports = forms.NullBooleanField(
659673
required=False,
660674
label=_('Has console ports'),

netbox/dcim/graphql/filters.py

Lines changed: 9 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)

netbox/dcim/graphql/types.py

Lines changed: 2 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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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(apps, schema_editor, model_name: str, target_field: str, related_name: str = 'instances'):
7+
"""
8+
Update a CounterCache field on the specified model by annotating the count of related instances.
9+
"""
10+
Model = apps.get_model('dcim', model_name)
11+
db_alias = schema_editor.connection.alias
12+
13+
count_subquery = (
14+
Model.objects.using(db_alias)
15+
.filter(pk=OuterRef('pk'))
16+
.annotate(_instance_count=Count(related_name))
17+
.values('_instance_count')
18+
)
19+
Model.objects.using(db_alias).update(**{target_field: Subquery(count_subquery)})
20+
21+
22+
def populate_device_type_device_count(apps, schema_editor):
23+
_populate_count_for_type(apps, schema_editor, 'DeviceType', 'device_count')
24+
25+
26+
def populate_module_type_module_count(apps, schema_editor):
27+
_populate_count_for_type(apps, schema_editor, 'ModuleType', 'module_count')
28+
29+
30+
class Migration(migrations.Migration):
31+
dependencies = [
32+
('dcim', '0217_owner'),
33+
]
34+
35+
operations = [
36+
migrations.AddField(
37+
model_name='devicetype',
38+
name='device_count',
39+
field=utilities.fields.CounterCacheField(
40+
default=0, editable=False, to_field='device_type', to_model='dcim.Device'
41+
),
42+
),
43+
migrations.RunPython(populate_device_type_device_count, migrations.RunPython.noop),
44+
migrations.AddField(
45+
model_name='moduletype',
46+
name='module_count',
47+
field=utilities.fields.CounterCacheField(
48+
default=0, editable=False, to_field='module_type', to_model='dcim.Module'
49+
),
50+
),
51+
migrations.RunPython(populate_module_type_module_count, migrations.RunPython.noop),
52+
]

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.

netbox/dcim/tables/devicetypes.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,6 @@ class DeviceTypeTable(PrimaryModelTable):
109109
template_code=WEIGHT,
110110
order_by=('_abs_weight', 'weight_unit')
111111
)
112-
instance_count = columns.LinkedCountColumn(
113-
viewname='dcim:device_list',
114-
url_params={'device_type_id': 'pk'},
115-
verbose_name=_('Instances')
116-
)
117112
console_port_template_count = tables.Column(
118113
verbose_name=_('Console Ports')
119114
)
@@ -144,16 +139,19 @@ class DeviceTypeTable(PrimaryModelTable):
144139
inventory_item_template_count = tables.Column(
145140
verbose_name=_('Inventory Items')
146141
)
142+
device_count = tables.Column(
143+
verbose_name=_('Device Count')
144+
)
147145

148146
class Meta(PrimaryModelTable.Meta):
149147
model = models.DeviceType
150148
fields = (
151149
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
152150
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
153-
'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
151+
'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
154152
)
155153
default_columns = (
156-
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
154+
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'device_count',
157155
)
158156

159157

0 commit comments

Comments
 (0)