Skip to content

Commit c66b9f8

Browse files
committed
feat(dcim): Add instance count filters for devices and modules
Introduces `instance_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.
1 parent a173a9b commit c66b9f8

File tree

7 files changed

+121
-13
lines changed

7 files changed

+121
-13
lines changed

netbox/dcim/api/serializers_/devicetypes.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
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+
instance_count = serializers.IntegerField(read_only=True)
4849

49-
# Related object counts
50+
# Related object counts (TODO: Remove in v4.5)
5051
device_count = RelatedObjectCountField('instances')
5152

5253
class Meta:
@@ -58,7 +59,7 @@ class Meta:
5859
'created', 'last_updated', 'device_count', 'console_port_template_count',
5960
'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
6061
'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
61-
'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count',
62+
'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count', 'instance_count',
6263
]
6364
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
6465

@@ -101,11 +102,14 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
101102
allow_null=True
102103
)
103104

105+
# Counter fields
106+
instance_count = serializers.IntegerField(read_only=True)
107+
104108
class Meta:
105109
model = ModuleType
106110
fields = [
107111
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
108112
'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
109-
'last_updated',
113+
'last_updated', 'instance_count',
110114
]
111115
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')

netbox/dcim/api/views.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
2121
from utilities.api import get_serializer_for_model
2222
from utilities.query_functions import CollateAsChar
23+
from utilities.query import count_related
2324
from virtualization.models import VirtualMachine
2425
from . import serializers
2526
from .exceptions import MissingFilterException
@@ -266,7 +267,9 @@ class ManufacturerViewSet(NetBoxModelViewSet):
266267
#
267268

268269
class DeviceTypeViewSet(NetBoxModelViewSet):
269-
queryset = DeviceType.objects.all()
270+
queryset = DeviceType.objects.annotate(instance_count=count_related(Device, 'device_type')).prefetch_related(
271+
'manufacturer', 'default_platform'
272+
)
270273
serializer_class = serializers.DeviceTypeSerializer
271274
filterset_class = filtersets.DeviceTypeFilterSet
272275

@@ -278,7 +281,7 @@ class ModuleTypeProfileViewSet(NetBoxModelViewSet):
278281

279282

280283
class ModuleTypeViewSet(NetBoxModelViewSet):
281-
queryset = ModuleType.objects.all()
284+
queryset = ModuleType.objects.annotate(instance_count=count_related(Module, 'module_type'))
282285
serializer_class = serializers.ModuleTypeSerializer
283286
filterset_class = filtersets.ModuleTypeFilterSet
284287

netbox/dcim/filtersets.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
from tenancy.models import *
1919
from users.models import User
2020
from utilities.filters import (
21-
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
22-
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
21+
AnnotatedCountFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
22+
MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
2323
)
2424
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
2525
from vpn.models import L2VPN
@@ -608,6 +608,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
608608
method='_inventory_items',
609609
label=_('Has inventory items'),
610610
)
611+
instance_count = AnnotatedCountFilter(
612+
field_name='instance_count',
613+
label=_('Instance count'),
614+
)
611615

612616
class Meta:
613617
model = DeviceType
@@ -743,6 +747,10 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
743747
method='_pass_through_ports',
744748
label=_('Has pass-through ports'),
745749
)
750+
instance_count = AnnotatedCountFilter(
751+
field_name='instance_count',
752+
label=_('Instance count'),
753+
)
746754

747755
class Meta:
748756
model = ModuleType

netbox/dcim/forms/filtersets.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,8 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
485485
fieldsets = (
486486
FieldSet('q', 'filter_id', 'tag'),
487487
FieldSet(
488-
'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware')
488+
'manufacturer_id', 'default_platform_id', 'part_number', 'instance_count',
489+
'subdevice_role', 'airflow', name=_('Hardware')
489490
),
490491
FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
491492
FieldSet(
@@ -509,6 +510,11 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
509510
label=_('Part number'),
510511
required=False
511512
)
513+
instance_count = forms.IntegerField(
514+
label=_('Instance count'),
515+
required=False,
516+
min_value=0,
517+
)
512518
subdevice_role = forms.MultipleChoiceField(
513519
label=_('Subdevice role'),
514520
choices=add_blank_choice(SubdeviceRoleChoices),
@@ -620,7 +626,8 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
620626
model = ModuleType
621627
fieldsets = (
622628
FieldSet('q', 'filter_id', 'tag'),
623-
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
629+
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'instance_count',
630+
'airflow', name=_('Hardware')),
624631
FieldSet(
625632
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
626633
'pass_through_ports', name=_('Components')
@@ -642,6 +649,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
642649
label=_('Part number'),
643650
required=False
644651
)
652+
instance_count = forms.IntegerField(
653+
label=_('Instance count'),
654+
required=False,
655+
min_value=0,
656+
)
645657
console_ports = forms.NullBooleanField(
646658
required=False,
647659
label=_('Has console ports'),

netbox/dcim/graphql/filters.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from typing import Annotated, TYPE_CHECKING
22

3-
from django.db.models import Q
3+
from django.db.models import QuerySet
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
@@ -19,6 +19,7 @@
1919
WeightFilterMixin,
2020
)
2121
from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin
22+
from utilities.query import count_related
2223
from .filter_mixins import (
2324
CabledObjectModelFilterMixin,
2425
ComponentModelFilterMixin,
@@ -326,6 +327,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
326327
)
327328
default_platform_id: ID | None = strawberry_django.filter_field()
328329
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
330+
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
331+
strawberry_django.filter_field()
332+
)
329333
u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
330334
strawberry_django.filter_field()
331335
)
@@ -384,6 +388,30 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
384388
module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
385389
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
386390

391+
@strawberry_django.filter_field
392+
def instance_count(
393+
self,
394+
info,
395+
queryset: QuerySet[models.DeviceType],
396+
value: ComparisonFilterLookup[int],
397+
prefix: str,
398+
) -> tuple[QuerySet[models.DeviceType], Q]:
399+
"""
400+
Filter by the number of related Device instances.
401+
402+
Annotates each DeviceType with instance_count and applies comparison lookups
403+
(exact, gt, gte, lt, lte, range).
404+
"""
405+
# Annotate each DeviceType with the number of Device instances which use the DeviceType
406+
qs = queryset.annotate(instance_count=count_related(models.Device, "device_type"))
407+
# NOTE: include the trailing "__" so Strawberry-Django appends lookups correctly
408+
return strawberry_django.process_filters(
409+
filters=value,
410+
queryset=qs,
411+
info=info,
412+
prefix=f"{prefix}instance_count__",
413+
)
414+
387415

388416
@strawberry_django.filter_type(models.FrontPort, lookups=True)
389417
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
@@ -665,6 +693,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
665693
profile_id: ID | None = strawberry_django.filter_field()
666694
model: FilterLookup[str] | None = strawberry_django.filter_field()
667695
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
696+
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
697+
strawberry_django.filter_field()
698+
)
668699
airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
669700
strawberry_django.filter_field()
670701
)
@@ -699,6 +730,30 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
699730
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
700731
) = strawberry_django.filter_field()
701732

733+
@strawberry_django.filter_field
734+
def instance_count(
735+
self,
736+
info,
737+
queryset: QuerySet[models.ModuleType],
738+
value: ComparisonFilterLookup[int],
739+
prefix: str,
740+
) -> tuple[QuerySet[models.ModuleType], Q]:
741+
"""
742+
Filter by the number of related Module instances.
743+
744+
Annotates each ModuleType with instance_count and applies comparison lookups
745+
(exact, gt, gte, lt, lte, range).
746+
"""
747+
# Annotate each ModuleType with the number of Module instances which use the ModuleType
748+
qs = queryset.annotate(instance_count=count_related(models.Module, "module_type"))
749+
# NOTE: include the trailing "__" so Strawberry-Django appends lookups correctly
750+
return strawberry_django.process_filters(
751+
filters=value,
752+
queryset=qs,
753+
info=info,
754+
prefix=f"{prefix}instance_count__",
755+
)
756+
702757

703758
@strawberry_django.filter_type(models.Platform, lookups=True)
704759
class PlatformFilter(OrganizationalModelFilterMixin):

netbox/netbox/filtersets.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class BaseFilterSet(django_filters.FilterSet):
4545
"""
4646
A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class.
4747
"""
48+
4849
FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
4950
FILTER_DEFAULTS.update({
5051
models.AutoField: {
@@ -179,6 +180,9 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter):
179180
field_name = existing_filter.field_name
180181
field = get_model_field(cls._meta.model, field_name)
181182

183+
# Check if this is an annotated field filter
184+
is_annotated_field = hasattr(existing_filter, '_is_annotated') and existing_filter._is_annotated
185+
182186
# Create new filters for each lookup expression in the map
183187
for lookup_name, lookup_expr in lookup_map.items():
184188
new_filter_name = f'{existing_filter_name}__{lookup_name}'
@@ -189,9 +193,14 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter):
189193
# The filter field has been explicitly defined on the filterset class so we must manually
190194
# create the new filter with the same type because there is no guarantee the defined type
191195
# is the same as the default type for the field
192-
if field is None:
196+
if field is None and not is_annotated_field:
197+
# Only raise error for non-annotated fields
193198
raise ValueError('Invalid field name/lookup on {}: {}'.format(existing_filter_name, field_name))
194-
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
199+
200+
# For annotated fields, we skip the resolve_field check
201+
if not is_annotated_field:
202+
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
203+
195204
filter_cls = type(existing_filter)
196205
if lookup_expr == 'empty':
197206
filter_cls = django_filters.BooleanFilter
@@ -205,6 +214,9 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter):
205214
distinct=existing_filter.distinct,
206215
**existing_filter_extra
207216
)
217+
# Mark the generated filter as annotated too
218+
if is_annotated_field:
219+
new_filter._is_annotated = True
208220
elif hasattr(existing_filter, 'custom_field'):
209221
# Filter is for a custom field
210222
custom_field = existing_filter.custom_field

netbox/utilities/filters.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from drf_spectacular.utils import extend_schema_field
88

99
__all__ = (
10+
'AnnotatedCountFilter',
1011
'ContentTypeFilter',
1112
'MultiValueArrayFilter',
1213
'MultiValueCharFilter',
@@ -55,6 +56,19 @@ def validate(self, value):
5556
# Filters
5657
#
5758

59+
@extend_schema_field(OpenApiTypes.INT32)
60+
class AnnotatedCountFilter(django_filters.NumberFilter):
61+
"""
62+
A filter for annotated count fields that supports automatic lookup generation
63+
while bypassing model field validation. Used for filtering on counts that are
64+
added via queryset annotations (e.g., instance_count).
65+
"""
66+
67+
def __init__(self, *args, **kwargs):
68+
self._is_annotated = True
69+
super().__init__(*args, **kwargs)
70+
71+
5872
@extend_schema_field(OpenApiTypes.STR)
5973
class MultiValueCharFilter(django_filters.MultipleChoiceFilter):
6074
field_class = multivalue_field_factory(forms.CharField)

0 commit comments

Comments
 (0)