Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions netbox/dcim/api/serializers_/devicetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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')

Expand Down Expand Up @@ -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')
7 changes: 5 additions & 2 deletions netbox/dcim/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
12 changes: 10 additions & 2 deletions netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions netbox/dcim/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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')
Expand All @@ -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'),
Expand Down
59 changes: 57 additions & 2 deletions netbox/dcim/graphql/filters.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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):
Expand Down
16 changes: 14 additions & 2 deletions netbox/netbox/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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}'
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions netbox/utilities/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from drf_spectacular.utils import extend_schema_field

__all__ = (
'AnnotatedCountFilter',
'ContentTypeFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter',
Expand Down Expand Up @@ -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)
Expand Down