diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 61e3833ec1b..0ec978121ec 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -45,8 +45,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer): device_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) + instance_count = serializers.IntegerField(read_only=True) - # Related object counts + # Related object counts (TODO: Remove in v4.5) device_count = RelatedObjectCountField('instances') class Meta: @@ -58,7 +59,7 @@ class Meta: 'created', 'last_updated', 'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count', 'interface_template_count', 'front_port_template_count', 'rear_port_template_count', - 'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count', + 'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count', 'instance_count', ] brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count') @@ -101,11 +102,14 @@ class ModuleTypeSerializer(NetBoxModelSerializer): allow_null=True ) + # Counter fields + instance_count = serializers.IntegerField(read_only=True) + class Meta: model = ModuleType fields = [ 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'last_updated', 'instance_count', ] brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ffc0ca4d629..89a8b32c721 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -20,6 +20,7 @@ from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from utilities.api import get_serializer_for_model from utilities.query_functions import CollateAsChar +from utilities.query import count_related from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -266,7 +267,9 @@ class ManufacturerViewSet(NetBoxModelViewSet): # class DeviceTypeViewSet(NetBoxModelViewSet): - queryset = DeviceType.objects.all() + queryset = DeviceType.objects.annotate(instance_count=count_related(Device, 'device_type')).prefetch_related( + 'manufacturer', 'default_platform' + ) serializer_class = serializers.DeviceTypeSerializer filterset_class = filtersets.DeviceTypeFilterSet @@ -278,7 +281,7 @@ class ModuleTypeProfileViewSet(NetBoxModelViewSet): class ModuleTypeViewSet(NetBoxModelViewSet): - queryset = ModuleType.objects.all() + queryset = ModuleType.objects.annotate(instance_count=count_related(Module, 'module_type')) serializer_class = serializers.ModuleTypeSerializer filterset_class = filtersets.ModuleTypeFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 37a0d99a2c3..782631e9d2b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,8 +18,8 @@ from tenancy.models import * from users.models import User from utilities.filters import ( - ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - NumericArrayFilter, TreeNodeMultipleChoiceFilter, + AnnotatedCountFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, + MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine from vpn.models import L2VPN @@ -608,6 +608,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): method='_inventory_items', label=_('Has inventory items'), ) + instance_count = AnnotatedCountFilter( + field_name='instance_count', + label=_('Instance count'), + ) class Meta: model = DeviceType @@ -743,6 +747,10 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet): method='_pass_through_ports', label=_('Has pass-through ports'), ) + instance_count = AnnotatedCountFilter( + field_name='instance_count', + label=_('Instance count'), + ) class Meta: model = ModuleType diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index daa3eef6555..ca1c65e1c4e 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -485,7 +485,8 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet( - 'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware') + 'manufacturer_id', 'default_platform_id', 'part_number', 'instance_count', + 'subdevice_role', 'airflow', name=_('Hardware') ), FieldSet('has_front_image', 'has_rear_image', name=_('Images')), FieldSet( @@ -509,6 +510,11 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): label=_('Part number'), required=False ) + instance_count = forms.IntegerField( + label=_('Instance count'), + required=False, + min_value=0, + ) subdevice_role = forms.MultipleChoiceField( label=_('Subdevice role'), choices=add_blank_choice(SubdeviceRoleChoices), @@ -620,7 +626,8 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), + FieldSet('profile_id', 'manufacturer_id', 'part_number', 'instance_count', + 'airflow', name=_('Hardware')), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_('Components') @@ -642,6 +649,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): label=_('Part number'), required=False ) + instance_count = forms.IntegerField( + label=_('Instance count'), + required=False, + min_value=0, + ) console_ports = forms.NullBooleanField( required=False, label=_('Has console ports'), diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index af2922e1362..f5574109b5d 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -1,10 +1,10 @@ from typing import Annotated, TYPE_CHECKING -from django.db.models import Q +from django.db.models import QuerySet import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import ComparisonFilterLookup, FilterLookup from core.graphql.filter_mixins import ChangeLogFilterMixin from dcim import models @@ -19,6 +19,7 @@ WeightFilterMixin, ) from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin +from utilities.query import count_related from .filter_mixins import ( CabledObjectModelFilterMixin, ComponentModelFilterMixin, @@ -326,6 +327,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig ) default_platform_id: ID | 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 = ( strawberry_django.filter_field() ) @@ -384,6 +388,30 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + @strawberry_django.filter_field + def instance_count( + self, + info, + queryset: QuerySet[models.DeviceType], + value: ComparisonFilterLookup[int], + prefix: str, + ) -> tuple[QuerySet[models.DeviceType], Q]: + """ + Filter by the number of related Device instances. + + Annotates each DeviceType with instance_count and applies comparison lookups + (exact, gt, gte, lt, lte, range). + """ + # Annotate each DeviceType with the number of Device instances which use the DeviceType + qs = queryset.annotate(instance_count=count_related(models.Device, "device_type")) + # NOTE: include the trailing "__" so Strawberry-Django appends lookups correctly + return strawberry_django.process_filters( + filters=value, + queryset=qs, + info=info, + prefix=f"{prefix}instance_count__", + ) + @strawberry_django.filter_type(models.FrontPort, lookups=True) class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): @@ -665,6 +693,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig profile_id: ID | None = strawberry_django.filter_field() model: 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 = ( strawberry_django.filter_field() ) @@ -699,6 +730,30 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None ) = strawberry_django.filter_field() + @strawberry_django.filter_field + def instance_count( + self, + info, + queryset: QuerySet[models.ModuleType], + value: ComparisonFilterLookup[int], + prefix: str, + ) -> tuple[QuerySet[models.ModuleType], Q]: + """ + Filter by the number of related Module instances. + + Annotates each ModuleType with instance_count and applies comparison lookups + (exact, gt, gte, lt, lte, range). + """ + # Annotate each ModuleType with the number of Module instances which use the ModuleType + qs = queryset.annotate(instance_count=count_related(models.Module, "module_type")) + # NOTE: include the trailing "__" so Strawberry-Django appends lookups correctly + return strawberry_django.process_filters( + filters=value, + queryset=qs, + info=info, + prefix=f"{prefix}instance_count__", + ) + @strawberry_django.filter_type(models.Platform, lookups=True) class PlatformFilter(OrganizationalModelFilterMixin): diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index ea24efe48ae..8ed98d28ef1 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -45,6 +45,7 @@ class BaseFilterSet(django_filters.FilterSet): """ A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class. """ + FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) FILTER_DEFAULTS.update({ models.AutoField: { @@ -179,6 +180,9 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): field_name = existing_filter.field_name field = get_model_field(cls._meta.model, field_name) + # Check if this is an annotated field filter + is_annotated_field = hasattr(existing_filter, '_is_annotated') and existing_filter._is_annotated + # Create new filters for each lookup expression in the map for lookup_name, lookup_expr in lookup_map.items(): new_filter_name = f'{existing_filter_name}__{lookup_name}' @@ -189,9 +193,14 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): # The filter field has been explicitly defined on the filterset class so we must manually # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field - if field is None: + if field is None and not is_annotated_field: + # Only raise error for non-annotated fields raise ValueError('Invalid field name/lookup on {}: {}'.format(existing_filter_name, field_name)) - resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + + # For annotated fields, we skip the resolve_field check + if not is_annotated_field: + resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + filter_cls = type(existing_filter) if lookup_expr == 'empty': filter_cls = django_filters.BooleanFilter @@ -205,6 +214,9 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): distinct=existing_filter.distinct, **existing_filter_extra ) + # Mark the generated filter as annotated too + if is_annotated_field: + new_filter._is_annotated = True elif hasattr(existing_filter, 'custom_field'): # Filter is for a custom field custom_field = existing_filter.custom_field diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 05454543e3c..b9ef645670c 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -7,6 +7,7 @@ from drf_spectacular.utils import extend_schema_field __all__ = ( + 'AnnotatedCountFilter', 'ContentTypeFilter', 'MultiValueArrayFilter', 'MultiValueCharFilter', @@ -55,6 +56,19 @@ def validate(self, value): # Filters # +@extend_schema_field(OpenApiTypes.INT32) +class AnnotatedCountFilter(django_filters.NumberFilter): + """ + A filter for annotated count fields that supports automatic lookup generation + while bypassing model field validation. Used for filtering on counts that are + added via queryset annotations (e.g., instance_count). + """ + + def __init__(self, *args, **kwargs): + self._is_annotated = True + super().__init__(*args, **kwargs) + + @extend_schema_field(OpenApiTypes.STR) class MultiValueCharFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.CharField)