From 2a78c0598479e1eb5adcc1c2a3812479c24d815f Mon Sep 17 00:00:00 2001 From: bctiemann Date: Wed, 25 Mar 2026 09:50:53 -0400 Subject: [PATCH] Closes #19034: Add calculated `RackReservation.unit_count`, with min/max filtering (#21665) --- docs/models/dcim/rackreservation.md | 4 ++++ netbox/dcim/api/serializers_/racks.py | 9 +++++++-- netbox/dcim/filtersets.py | 20 ++++++++++++++++++++ netbox/dcim/forms/filtersets.py | 10 +++++++++- netbox/dcim/graphql/filters.py | 1 + netbox/dcim/graphql/types.py | 12 ++++++++++++ netbox/dcim/tables/racks.py | 11 ++++++++--- netbox/dcim/tests/test_api.py | 9 +++++++++ netbox/dcim/tests/test_filtersets.py | 14 +++++++++++--- netbox/dcim/ui/panels.py | 1 + netbox/dcim/views.py | 18 +++++++++++++----- 11 files changed, 95 insertions(+), 14 deletions(-) diff --git a/docs/models/dcim/rackreservation.md b/docs/models/dcim/rackreservation.md index 8eaa11af8..9e15302ee 100644 --- a/docs/models/dcim/rackreservation.md +++ b/docs/models/dcim/rackreservation.md @@ -12,6 +12,10 @@ The [rack](./rack.md) being reserved. The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7. +### Total U's + +A calculated (read-only) field that reflects the total number of units in the reservation. Can be filtered upon using `unit_count_min` and `unit_count_max` parameters in the UI or API. + ### Status The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.) diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 8a7294f6b..fcac4f567 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -173,11 +173,16 @@ class RackReservationSerializer(PrimaryModelSerializer): allow_null=True, ) + unit_count = serializers.SerializerMethodField() + + def get_unit_count(self, obj): + return len(obj.units) + class Meta: model = RackReservation fields = [ - 'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user', - 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'rack', 'units', 'unit_count', 'status', 'created', 'last_updated', + 'user', 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', ] brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3c966d49f..4f840b988 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,6 +1,7 @@ import django_filters import netaddr from django.contrib.contenttypes.models import ContentType +from django.db.models import Func, IntegerField from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -606,11 +607,30 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): field_name='units', lookup_expr='contains' ) + unit_count_min = django_filters.NumberFilter( + field_name='unit_count', + lookup_expr='gte', + label=_('Minimum unit count'), + ) + unit_count_max = django_filters.NumberFilter( + field_name='unit_count', + lookup_expr='lte', + label=_('Maximum unit count'), + ) class Meta: model = RackReservation fields = ('id', 'created', 'description') + def filter_queryset(self, queryset): + # Annotate unit_count here so unit_count_min/unit_count_max filters can reference it. + # When called from the list view the queryset is already annotated; Django silently + # overwrites a duplicate annotation with the same expression, so this is safe. + queryset = queryset.annotate( + unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) + ) + return super().filter_queryset(queryset) + def search(self, queryset, name, value): if not value.strip(): return queryset diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1584a09cc..d877250b8 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -475,7 +475,7 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): model = RackReservation fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('status', 'user_id', name=_('Reservation')), + FieldSet('status', 'user_id', 'unit_count_min', 'unit_count_max', name=_('Reservation')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'rack_id', name=_('Rack')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), @@ -534,6 +534,14 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): required=False, label=_('User') ) + unit_count_min = forms.IntegerField( + required=False, + label=_("Minimum U's") + ) + unit_count_max = forms.IntegerField( + required=False, + label=_("Maximum U's") + ) tag = TagFilterField(model) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index bb38a1514..041f5fab3 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -997,6 +997,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter): units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) + unit_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() user_id: ID | None = strawberry_django.filter_field() description: StrFilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index b6af83cb0..539ba46eb 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated import strawberry import strawberry_django +from django.db.models import Func, IntegerField from core.graphql.mixins import ChangelogMixin from dcim import models @@ -803,6 +804,17 @@ class RackReservationType(PrimaryObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None user: Annotated["UserType", strawberry.lazy('users.graphql.types')] + @classmethod + def get_queryset(cls, queryset, info, **kwargs): + queryset = super().get_queryset(queryset, info, **kwargs) + return queryset.annotate( + unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) + ) + + @strawberry.field + def unit_count(self) -> int: + return len(self.units) + @strawberry_django.type( models.RackRole, diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1ec8ff965..2ef1bd72c 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -241,6 +241,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable): orderable=False, verbose_name=_('Units') ) + unit_count = tables.Column( + verbose_name=_("Total U's") + ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) @@ -251,7 +254,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable): class Meta(PrimaryModelTable.Meta): model = RackReservation fields = ( - 'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'status', 'user', 'tenant', - 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'unit_count', 'status', + 'user', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'reservation', 'site', 'rack', 'unit_list', 'unit_count', 'status', 'user', 'description', ) - default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bbdcc3f36..df12d0dec 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -570,6 +570,15 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): }, ] + def test_unit_count(self): + """unit_count should reflect the number of units in the reservation.""" + url = reverse('dcim-api:rackreservation-list') + self.add_permissions('dcim.view_rackreservation') + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + for result in response.data['results']: + self.assertEqual(result['unit_count'], len(result['units'])) + class ManufacturerTest(APIViewTestCases.APIViewTestCase): model = Manufacturer diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d91e24d79..4f3212a75 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1205,7 +1205,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): reservations = ( RackReservation( rack=racks[0], - units=[1, 2, 3], + units=[1, 2], status=RackReservationStatusChoices.STATUS_ACTIVE, user=users[0], tenant=tenants[0], @@ -1213,7 +1213,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): ), RackReservation( rack=racks[1], - units=[4, 5, 6], + units=[1, 2, 3], status=RackReservationStatusChoices.STATUS_PENDING, user=users[1], tenant=tenants[1], @@ -1221,7 +1221,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): ), RackReservation( rack=racks[2], - units=[7, 8, 9], + units=[1, 2, 3, 4], status=RackReservationStatusChoices.STATUS_STALE, user=users[2], tenant=tenants[2], @@ -1291,6 +1291,14 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_unit_count(self): + params = {'unit_count_min': 3} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'unit_count_max': 3} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'unit_count_min': 3, 'unit_count_max': 3} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_tenant_group(self): tenant_groups = TenantGroup.objects.all()[:2] params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index a7b56217a..faab487d0 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -70,6 +70,7 @@ class RackRolePanel(panels.OrganizationalObjectPanel): class RackReservationPanel(panels.ObjectAttributesPanel): units = attrs.TextAttr('unit_list') + unit_count = attrs.TextAttr('unit_count', label=_("Total U's")) status = attrs.ChoiceAttr('status') tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') user = attrs.RelatedObjectAttr('user') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 32bd1df3d..571e9ac92 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,7 +3,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import router, transaction -from django.db.models import Prefetch +from django.db.models import Func, IntegerField, Prefetch from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -1227,7 +1227,9 @@ class RackBulkDeleteView(generic.BulkDeleteView): @register_model_view(RackReservation, 'list', path='', detail=False) class RackReservationListView(generic.ObjectListView): - queryset = RackReservation.objects.all() + queryset = RackReservation.objects.annotate( + unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) + ) filterset = filtersets.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable @@ -1236,7 +1238,9 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): - queryset = RackReservation.objects.all() + queryset = RackReservation.objects.annotate( + unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) + ) layout = layout.SimpleLayout( left_panels=[ panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'group', 'name']), @@ -1289,7 +1293,9 @@ class RackReservationImportView(generic.BulkImportView): @register_model_view(RackReservation, 'bulk_edit', path='edit', detail=False) class RackReservationBulkEditView(generic.BulkEditView): - queryset = RackReservation.objects.all() + queryset = RackReservation.objects.annotate( + unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) + ) filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable form = forms.RackReservationBulkEditForm @@ -1297,7 +1303,9 @@ class RackReservationBulkEditView(generic.BulkEditView): @register_model_view(RackReservation, 'bulk_delete', path='delete', detail=False) class RackReservationBulkDeleteView(generic.BulkDeleteView): - queryset = RackReservation.objects.all() + queryset = RackReservation.objects.annotate( + unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) + ) filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable