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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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.
### 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.)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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):
model = Manufacturer

View File

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

View File

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

View File

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