Skip to content

Commit c8d4d85

Browse files
committed
feat(dcim): Add instance count filters for devices and modules
Introduces `has_instances` and `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, along with comprehensive tests for validation. Fixes #19523
1 parent 873372f commit c8d4d85

File tree

4 files changed

+264
-5
lines changed

4 files changed

+264
-5
lines changed

netbox/dcim/filtersets.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
2222
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
2323
)
24+
from utilities.query import count_related
2425
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
2526
from vpn.models import L2VPN
2627
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
@@ -564,6 +565,30 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
564565
lookup_expr='in',
565566
label=_('Default platform (slug)'),
566567
)
568+
has_instances = django_filters.BooleanFilter(
569+
label=_('Has instances'),
570+
method='_has_instances',
571+
)
572+
instance_count = django_filters.NumberFilter(
573+
lookup_expr='exact',
574+
method='_instance_count',
575+
)
576+
instance_count__gt = django_filters.NumberFilter(
577+
lookup_expr='gt',
578+
method='_instance_count',
579+
)
580+
instance_count__gte = django_filters.NumberFilter(
581+
lookup_expr='gte',
582+
method='_instance_count',
583+
)
584+
instance_count__lt = django_filters.NumberFilter(
585+
lookup_expr='lt',
586+
method='_instance_count',
587+
)
588+
instance_count__lte = django_filters.NumberFilter(
589+
lookup_expr='lte',
590+
method='_instance_count',
591+
)
567592
has_front_image = django_filters.BooleanFilter(
568593
label=_('Has a front image'),
569594
method='_has_front_image'
@@ -639,6 +664,20 @@ def search(self, queryset, name, value):
639664
Q(comments__icontains=value)
640665
)
641666

667+
def _has_instances(self, queryset, name, value):
668+
if value is None:
669+
return queryset
670+
qs = queryset.annotate(instance_count=count_related(Device, 'device_type'))
671+
return qs.filter(instance_count__gt=0) if value else qs.filter(instance_count=0)
672+
673+
def _instance_count(self, queryset, name, value):
674+
if value is None:
675+
return queryset
676+
# Derive the lookup from the filter that invoked us
677+
lookup = getattr(self.filters[name], 'lookup_expr', 'exact')
678+
qs = queryset.annotate(instance_count=count_related(Device, 'device_type'))
679+
return qs.filter(**{f"instance_count__{lookup}": value})
680+
642681
def _has_front_image(self, queryset, name, value):
643682
if value:
644683
return queryset.exclude(front_image='')
@@ -719,6 +758,30 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
719758
to_field_name='slug',
720759
label=_('Manufacturer (slug)'),
721760
)
761+
has_instances = django_filters.BooleanFilter(
762+
label=_('Has instances'),
763+
method='_has_instances',
764+
)
765+
instance_count = django_filters.NumberFilter(
766+
lookup_expr='exact',
767+
method='_instance_count',
768+
)
769+
instance_count__gt = django_filters.NumberFilter(
770+
lookup_expr='gt',
771+
method='_instance_count',
772+
)
773+
instance_count__gte = django_filters.NumberFilter(
774+
lookup_expr='gte',
775+
method='_instance_count',
776+
)
777+
instance_count__lt = django_filters.NumberFilter(
778+
lookup_expr='lt',
779+
method='_instance_count',
780+
)
781+
instance_count__lte = django_filters.NumberFilter(
782+
lookup_expr='lte',
783+
method='_instance_count',
784+
)
722785
console_ports = django_filters.BooleanFilter(
723786
method='_console_ports',
724787
label=_('Has console ports'),
@@ -759,6 +822,20 @@ def search(self, queryset, name, value):
759822
Q(comments__icontains=value)
760823
)
761824

825+
def _has_instances(self, queryset, name, value):
826+
if value is None:
827+
return queryset
828+
qs = queryset.annotate(instance_count=count_related(Module, 'module_type'))
829+
return qs.filter(instance_count__gt=0) if value else qs.filter(instance_count=0)
830+
831+
def _instance_count(self, queryset, name, value):
832+
if value is None:
833+
return queryset
834+
# Derive the lookup from the filter that invoked us
835+
lookup = getattr(self.filters[name], 'lookup_expr', 'exact')
836+
qs = queryset.annotate(instance_count=count_related(Module, 'module_type'))
837+
return qs.filter(**{f"instance_count__{lookup}": value})
838+
762839
def _console_ports(self, queryset, name, value):
763840
return queryset.exclude(consoleporttemplates__isnull=value)
764841

netbox/dcim/forms/filtersets.py

Lines changed: 17 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', 'has_instances', 'subdevice_role',
489+
'airflow', name=_('Hardware')
489490
),
490491
FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
491492
FieldSet(
@@ -509,6 +510,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
509510
label=_('Part number'),
510511
required=False
511512
)
513+
has_instances = forms.NullBooleanField(
514+
required=False,
515+
label=_('Has instances'),
516+
widget=forms.Select(
517+
choices=BOOLEAN_WITH_BLANK_CHOICES
518+
)
519+
)
512520
subdevice_role = forms.MultipleChoiceField(
513521
label=_('Subdevice role'),
514522
choices=add_blank_choice(SubdeviceRoleChoices),
@@ -620,7 +628,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
620628
model = ModuleType
621629
fieldsets = (
622630
FieldSet('q', 'filter_id', 'tag'),
623-
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
631+
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'has_instances', 'airflow', name=_('Hardware')),
624632
FieldSet(
625633
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
626634
'pass_through_ports', name=_('Components')
@@ -642,6 +650,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
642650
label=_('Part number'),
643651
required=False
644652
)
653+
has_instances = forms.NullBooleanField(
654+
required=False,
655+
label=_('Has instances'),
656+
widget=forms.Select(
657+
choices=BOOLEAN_WITH_BLANK_CHOICES
658+
)
659+
)
645660
console_ports = forms.NullBooleanField(
646661
required=False,
647662
label=_('Has console ports'),

netbox/dcim/graphql/filters.py

Lines changed: 99 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 Q, 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 BaseFilterLookup, 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,51 @@ 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 has_instances(
393+
self,
394+
queryset: QuerySet[models.DeviceType],
395+
value: BaseFilterLookup[bool],
396+
prefix: str,
397+
) -> tuple[QuerySet[models.DeviceType], Q]:
398+
"""
399+
Filters a queryset of device types based on whether they have associated instances.
400+
"""
401+
# Annotate each DeviceType with the number of Device instances which use the DeviceType
402+
qs = queryset.annotate(instance_count=count_related(models.Device, "device_type"))
403+
404+
# IMPORTANT: read the actual boolean from the lookup container
405+
exact = getattr(value, "exact", None)
406+
if exact is None:
407+
return qs, Q()
408+
409+
cond = Q(**{f"{prefix}instance_count__gt": 0}) if exact else Q(**{f"{prefix}instance_count": 0})
410+
return qs, cond
411+
412+
@strawberry_django.filter_field
413+
def instance_count(
414+
self,
415+
info,
416+
queryset: QuerySet[models.DeviceType],
417+
value: ComparisonFilterLookup[int],
418+
prefix: str,
419+
) -> tuple[QuerySet[models.DeviceType], Q]:
420+
"""
421+
Filter by the number of related Device instances.
422+
423+
Annotates each DeviceType with instance_count and applies comparison lookups
424+
(exact, gt, gte, lt, lte, range).
425+
"""
426+
# Annotate each DeviceType with the number of Device instances which use the DeviceType
427+
qs = queryset.annotate(instance_count=count_related(models.Device, "device_type"))
428+
# NOTE: include the trailing "__" so Strawberry-Django appends lookups correctly
429+
return strawberry_django.process_filters(
430+
filters=value,
431+
queryset=qs,
432+
info=info,
433+
prefix=f"{prefix}instance_count__",
434+
)
435+
387436

388437
@strawberry_django.filter_type(models.FrontPort, lookups=True)
389438
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
@@ -665,6 +714,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
665714
profile_id: ID | None = strawberry_django.filter_field()
666715
model: FilterLookup[str] | None = strawberry_django.filter_field()
667716
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
717+
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
718+
strawberry_django.filter_field()
719+
)
668720
airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
669721
strawberry_django.filter_field()
670722
)
@@ -699,6 +751,51 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
699751
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
700752
) = strawberry_django.filter_field()
701753

754+
@strawberry_django.filter_field
755+
def has_instances(
756+
self,
757+
queryset: QuerySet[models.ModuleType],
758+
value: BaseFilterLookup[bool],
759+
prefix: str,
760+
) -> tuple[QuerySet[models.ModuleType], Q]:
761+
"""
762+
Filters a queryset of module types based on whether they have associated instances.
763+
"""
764+
# Annotate each ModuleType with the number of Module instances which use the ModuleType
765+
qs = queryset.annotate(instance_count=count_related(models.Module, "module_type"))
766+
767+
# IMPORTANT: read the actual boolean from the lookup container
768+
exact = getattr(value, "exact", None)
769+
if exact is None:
770+
return qs, Q()
771+
772+
cond = Q(**{f"{prefix}instance_count__gt": 0}) if exact else Q(**{f"{prefix}instance_count": 0})
773+
return qs, cond
774+
775+
@strawberry_django.filter_field
776+
def instance_count(
777+
self,
778+
info,
779+
queryset: QuerySet[models.ModuleType],
780+
value: ComparisonFilterLookup[int],
781+
prefix: str,
782+
) -> tuple[QuerySet[models.ModuleType], Q]:
783+
"""
784+
Filter by the number of related Module instances.
785+
786+
Annotates each ModuleType with instance_count and applies comparison lookups
787+
(exact, gt, gte, lt, lte, range).
788+
"""
789+
# Annotate each ModuleType with the number of Module instances which use the ModuleType
790+
qs = queryset.annotate(instance_count=count_related(models.Module, "module_type"))
791+
# NOTE: include the trailing "__" so Strawberry-Django appends lookups correctly
792+
return strawberry_django.process_filters(
793+
filters=value,
794+
queryset=qs,
795+
info=info,
796+
prefix=f"{prefix}instance_count__",
797+
)
798+
702799

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

0 commit comments

Comments
 (0)