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

This commit is contained in:
bctiemann
2026-03-25 09:50:53 -04:00
committed by GitHub
parent bc66d9f136
commit 2a78c05984
11 changed files with 95 additions and 14 deletions

View File

@@ -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.)

View File

@@ -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', 'id', 'url', 'display_url', 'display', 'rack', 'units', 'unit_count', 'status', 'created', 'last_updated',
'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', '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')

View File

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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,

View File

@@ -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', 'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'unit_count', 'status',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', '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')

View File

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

View File

@@ -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]}

View File

@@ -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')

View File

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