mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-24 09:48:45 +02:00
This commit is contained in:
@@ -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.)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user