Răsfoiți Sursa

Closes #19034: Add calculated `RackReservation.unit_count`, with min/max filtering (#21665)

bctiemann 1 zi în urmă
părinte
comite
2a78c05984

+ 4 - 0
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.
 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
 ### 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.)
 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.)

+ 7 - 2
netbox/dcim/api/serializers_/racks.py

@@ -173,11 +173,16 @@ class RackReservationSerializer(PrimaryModelSerializer):
         allow_null=True,
         allow_null=True,
     )
     )
 
 
+    unit_count = serializers.SerializerMethodField()
+
+    def get_unit_count(self, obj):
+        return len(obj.units)
+
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
         fields = [
         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')
         brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
 
 

+ 20 - 0
netbox/dcim/filtersets.py

@@ -1,6 +1,7 @@
 import django_filters
 import django_filters
 import netaddr
 import netaddr
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Func, IntegerField
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
@@ -606,11 +607,30 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         field_name='units',
         field_name='units',
         lookup_expr='contains'
         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:
     class Meta:
         model = RackReservation
         model = RackReservation
         fields = ('id', 'created', 'description')
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset

+ 9 - 1
netbox/dcim/forms/filtersets.py

@@ -475,7 +475,7 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = RackReservation
     model = RackReservation
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         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('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'rack_id', name=_('Rack')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
@@ -534,6 +534,14 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         required=False,
         required=False,
         label=_('User')
         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)
     tag = TagFilterField(model)
 
 
 
 

+ 1 - 0
netbox/dcim/graphql/filters.py

@@ -997,6 +997,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         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: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     description: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 12 - 0
netbox/dcim/graphql/types.py

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+from django.db.models import Func, IntegerField
 
 
 from core.graphql.mixins import ChangelogMixin
 from core.graphql.mixins import ChangelogMixin
 from dcim import models
 from dcim import models
@@ -803,6 +804,17 @@ class RackReservationType(PrimaryObjectType):
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')]
     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(
 @strawberry_django.type(
     models.RackRole,
     models.RackRole,

+ 8 - 3
netbox/dcim/tables/racks.py

@@ -241,6 +241,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
         orderable=False,
         orderable=False,
         verbose_name=_('Units')
         verbose_name=_('Units')
     )
     )
+    unit_count = tables.Column(
+        verbose_name=_("Total U's")
+    )
     status = columns.ChoiceFieldColumn(
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status'),
         verbose_name=_('Status'),
     )
     )
@@ -251,7 +254,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
     class Meta(PrimaryModelTable.Meta):
         model = RackReservation
         model = RackReservation
         fields = (
         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')

+ 9 - 0
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):
 class ManufacturerTest(APIViewTestCases.APIViewTestCase):
     model = Manufacturer
     model = Manufacturer

+ 11 - 3
netbox/dcim/tests/test_filtersets.py

@@ -1205,7 +1205,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         reservations = (
         reservations = (
             RackReservation(
             RackReservation(
                 rack=racks[0],
                 rack=racks[0],
-                units=[1, 2, 3],
+                units=[1, 2],
                 status=RackReservationStatusChoices.STATUS_ACTIVE,
                 status=RackReservationStatusChoices.STATUS_ACTIVE,
                 user=users[0],
                 user=users[0],
                 tenant=tenants[0],
                 tenant=tenants[0],
@@ -1213,7 +1213,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
             ),
             ),
             RackReservation(
             RackReservation(
                 rack=racks[1],
                 rack=racks[1],
-                units=[4, 5, 6],
+                units=[1, 2, 3],
                 status=RackReservationStatusChoices.STATUS_PENDING,
                 status=RackReservationStatusChoices.STATUS_PENDING,
                 user=users[1],
                 user=users[1],
                 tenant=tenants[1],
                 tenant=tenants[1],
@@ -1221,7 +1221,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
             ),
             ),
             RackReservation(
             RackReservation(
                 rack=racks[2],
                 rack=racks[2],
-                units=[7, 8, 9],
+                units=[1, 2, 3, 4],
                 status=RackReservationStatusChoices.STATUS_STALE,
                 status=RackReservationStatusChoices.STATUS_STALE,
                 user=users[2],
                 user=users[2],
                 tenant=tenants[2],
                 tenant=tenants[2],
@@ -1291,6 +1291,14 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_tenant_group(self):
         tenant_groups = TenantGroup.objects.all()[:2]
         tenant_groups = TenantGroup.objects.all()[:2]
         params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
         params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}

+ 1 - 0
netbox/dcim/ui/panels.py

@@ -70,6 +70,7 @@ class RackRolePanel(panels.OrganizationalObjectPanel):
 
 
 class RackReservationPanel(panels.ObjectAttributesPanel):
 class RackReservationPanel(panels.ObjectAttributesPanel):
     units = attrs.TextAttr('unit_list')
     units = attrs.TextAttr('unit_list')
+    unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
     status = attrs.ChoiceAttr('status')
     status = attrs.ChoiceAttr('status')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     user = attrs.RelatedObjectAttr('user')
     user = attrs.RelatedObjectAttr('user')

+ 13 - 5
netbox/dcim/views.py

@@ -3,7 +3,7 @@ from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import router, transaction
 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.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -1227,7 +1227,9 @@ class RackBulkDeleteView(generic.BulkDeleteView):
 
 
 @register_model_view(RackReservation, 'list', path='', detail=False)
 @register_model_view(RackReservation, 'list', path='', detail=False)
 class RackReservationListView(generic.ObjectListView):
 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 = filtersets.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
@@ -1236,7 +1238,9 @@ class RackReservationListView(generic.ObjectListView):
 
 
 @register_model_view(RackReservation)
 @register_model_view(RackReservation)
 class RackReservationView(generic.ObjectView):
 class RackReservationView(generic.ObjectView):
-    queryset = RackReservation.objects.all()
+    queryset = RackReservation.objects.annotate(
+        unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
+    )
     layout = layout.SimpleLayout(
     layout = layout.SimpleLayout(
         left_panels=[
         left_panels=[
             panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'group', 'name']),
             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)
 @register_model_view(RackReservation, 'bulk_edit', path='edit', detail=False)
 class RackReservationBulkEditView(generic.BulkEditView):
 class RackReservationBulkEditView(generic.BulkEditView):
-    queryset = RackReservation.objects.all()
+    queryset = RackReservation.objects.annotate(
+        unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
+    )
     filterset = filtersets.RackReservationFilterSet
     filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     table = tables.RackReservationTable
     form = forms.RackReservationBulkEditForm
     form = forms.RackReservationBulkEditForm
@@ -1297,7 +1303,9 @@ class RackReservationBulkEditView(generic.BulkEditView):
 
 
 @register_model_view(RackReservation, 'bulk_delete', path='delete', detail=False)
 @register_model_view(RackReservation, 'bulk_delete', path='delete', detail=False)
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
-    queryset = RackReservation.objects.all()
+    queryset = RackReservation.objects.annotate(
+        unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
+    )
     filterset = filtersets.RackReservationFilterSet
     filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     table = tables.RackReservationTable