Closes #18984: Add status field to Rack model (#20080)

This commit is contained in:
Jeremy Stretch 2025-08-12 13:35:50 -04:00 committed by GitHub
parent 032bd52dc7
commit bb57021197
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 230 additions and 96 deletions

View File

@ -12,6 +12,13 @@ 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.
### 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.)
!!! tip
Additional statuses may be defined by setting `RackReservation.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### User ### User
The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users. The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.

View File

@ -137,17 +137,29 @@ class RackSerializer(RackBaseSerializer):
class RackReservationSerializer(NetBoxModelSerializer): class RackReservationSerializer(NetBoxModelSerializer):
rack = RackSerializer(nested=True) rack = RackSerializer(
user = UserSerializer(nested=True) nested=True,
tenant = TenantSerializer(nested=True, required=False, allow_null=True) )
status = ChoiceField(
choices=RackReservationStatusChoices,
required=False,
)
user = UserSerializer(
nested=True,
)
tenant = TenantSerializer(
nested=True,
required=False,
allow_null=True,
)
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
'description', 'comments', 'tags', 'custom_fields', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
] ]
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units') brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
class RackElevationDetailFilterSerializer(serializers.Serializer): class RackElevationDetailFilterSerializer(serializers.Serializer):

View File

@ -139,6 +139,24 @@ class RackAirflowChoices(ChoiceSet):
] ]
#
# Rack reservations
#
class RackReservationStatusChoices(ChoiceSet):
key = 'RackReservation.status'
STATUS_PENDING = 'pending'
STATUS_ACTIVE = 'active'
STATUS_STALE = 'stale'
CHOICES = [
(STATUS_PENDING, _('Pending'), 'cyan'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_STALE, _('Stale'), 'orange'),
]
# #
# DeviceTypes # DeviceTypes
# #

View File

@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Location (slug)'), label=_('Location (slug)'),
) )
status = django_filters.MultipleChoiceFilter(
choices=RackReservationStatusChoices,
null_value=None
)
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),

View File

@ -476,6 +476,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
class RackReservationBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(RackReservationStatusChoices),
required=False,
initial=''
)
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_('User'), label=_('User'),
queryset=User.objects.order_by('username'), queryset=User.objects.order_by('username'),
@ -495,7 +501,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
FieldSet('user', 'tenant', 'description'), FieldSet('status', 'user', 'tenant', 'description'),
) )
nullable_fields = ('comments',) nullable_fields = ('comments',)

View File

@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
required=True, required=True,
help_text=_('Comma-separated list of individual unit numbers') help_text=_('Comma-separated list of individual unit numbers')
) )
status = CSVChoiceField(
label=_('Status'),
choices=RackReservationStatusChoices,
help_text=_('Operational status')
)
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'), label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags') fields = ('site', 'location', 'rack', 'units', 'status', 'tenant', 'description', 'comments', 'tags')
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)

View File

@ -417,7 +417,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('user_id', name=_('User')), FieldSet('status', 'user_id', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
) )
@ -458,6 +458,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
}, },
label=_('Rack') label=_('Rack')
) )
status = forms.MultipleChoiceField(
label=_('Status'),
choices=RackReservationStatusChoices,
required=False
)
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
required=False, required=False,

View File

@ -336,14 +336,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')), FieldSet('rack', 'units', 'status', 'user', 'description', 'tags', name=_('Reservation')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
) )
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ fields = [
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags', 'rack', 'units', 'status', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
] ]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0212_platform_rebuild'),
]
operations = [
migrations.AddField(
model_name='rackreservation',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@ -673,6 +673,12 @@ class RackReservation(PrimaryModel):
verbose_name=_('units'), verbose_name=_('units'),
base_field=models.PositiveSmallIntegerField() base_field=models.PositiveSmallIntegerField()
) )
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=RackReservationStatusChoices,
default=RackReservationStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -733,6 +739,9 @@ class RackReservation(PrimaryModel):
def unit_list(self): def unit_list(self):
return array_to_string(self.units) return array_to_string(self.units)
def get_status_color(self):
return RackReservationStatusChoices.colors.get(self.status)
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
objectchange.related_object = self.rack objectchange.related_object = self.rack

View File

@ -229,6 +229,9 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
orderable=False, orderable=False,
verbose_name=_('Units') verbose_name=_('Units')
) )
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
) )
@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = RackReservation model = RackReservation
fields = ( fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

View File

@ -465,7 +465,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
class RackReservationTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase):
model = RackReservation model = RackReservation
brief_fields = ['description', 'display', 'id', 'units', 'url', 'user'] brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -483,9 +483,24 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
rack_reservations = ( rack_reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=user, description='Reservation #1'), RackReservation(
RackReservation(rack=racks[0], units=[4, 5, 6], user=user, description='Reservation #2'), rack=racks[0],
RackReservation(rack=racks[0], units=[7, 8, 9], user=user, description='Reservation #3'), units=[1, 2, 3],
user=user,
description='Reservation #1',
),
RackReservation(
rack=racks[0],
units=[4, 5, 6],
user=user,
description='Reservation #2'
),
RackReservation(
rack=racks[0],
units=[7, 8, 9],
user=user,
description='Reservation #3',
),
) )
RackReservation.objects.bulk_create(rack_reservations) RackReservation.objects.bulk_create(rack_reservations)
@ -493,18 +508,21 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
{ {
'rack': racks[1].pk, 'rack': racks[1].pk,
'units': [10, 11, 12], 'units': [10, 11, 12],
'status': RackReservationStatusChoices.STATUS_ACTIVE,
'user': user.pk, 'user': user.pk,
'description': 'Reservation #4', 'description': 'Reservation #4',
}, },
{ {
'rack': racks[1].pk, 'rack': racks[1].pk,
'units': [13, 14, 15], 'units': [13, 14, 15],
'status': RackReservationStatusChoices.STATUS_PENDING,
'user': user.pk, 'user': user.pk,
'description': 'Reservation #5', 'description': 'Reservation #5',
}, },
{ {
'rack': racks[1].pk, 'rack': racks[1].pk,
'units': [16, 17, 18], 'units': [16, 17, 18],
'status': RackReservationStatusChoices.STATUS_STALE,
'user': user.pk, 'user': user.pk,
'description': 'Reservation #6', 'description': 'Reservation #6',
}, },

View File

@ -1141,9 +1141,30 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
reservations = ( reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'), RackReservation(
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'), rack=racks[0],
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2], description='foobar3'), units=[1, 2, 3],
status=RackReservationStatusChoices.STATUS_ACTIVE,
user=users[0],
tenant=tenants[0],
description='foobar1',
),
RackReservation(
rack=racks[1],
units=[4, 5, 6],
status=RackReservationStatusChoices.STATUS_PENDING,
user=users[1],
tenant=tenants[1],
description='foobar2',
),
RackReservation(
rack=racks[2],
units=[7, 8, 9],
status=RackReservationStatusChoices.STATUS_STALE,
user=users[2],
tenant=tenants[2],
description='foobar3',
),
) )
RackReservation.objects.bulk_create(reservations) RackReservation.objects.bulk_create(reservations)
@ -1179,6 +1200,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]} params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_user(self): def test_user(self):
users = User.objects.all()[:2] users = User.objects.all()[:2]
params = {'user_id': [users[0].pk, users[1].pk]} params = {'user_id': [users[0].pk, users[1].pk]}

View File

@ -337,6 +337,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'rack': rack.pk, 'rack': rack.pk,
'units': "10,11,12", 'units': "10,11,12",
'status': RackReservationStatusChoices.STATUS_PENDING,
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'Rack reservation', 'description': 'Rack reservation',
@ -344,10 +345,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
'site,location,rack,units,description', 'site,location,rack,units,status,description',
'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1', 'Site 1,Location 1,Rack 1,"10,11,12",active,Reservation 1',
'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2', 'Site 1,Location 1,Rack 1,"13,14,15",pending,Reservation 2',
'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', 'Site 1,Location 1,Rack 1,"16,17,18",stale,Reservation 3',
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -358,6 +359,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'status': RackReservationStatusChoices.STATUS_STALE,
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'New description', 'description': 'New description',

View File

@ -13,83 +13,87 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-12 col-xl-5"> <div class="col col-12 col-xl-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Rack" %}</h2> <h2 class="card-header">{% trans "Rack" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Region" %}</th> <th scope="row">{% trans "Region" %}</th>
<td> <td>
{% nested_tree object.rack.site.region %} {% nested_tree object.rack.site.region %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Site" %}</th>
<td>{{ object.rack.site|linkify }}</td> <td>{{ object.rack.site|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Location" %}</th> <th scope="row">{% trans "Location" %}</th>
<td>{{ object.rack.location|linkify|placeholder }}</td> <td>{{ object.rack.location|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Rack" %}</th> <th scope="row">{% trans "Rack" %}</th>
<td>{{ object.rack|linkify }}</td> <td>{{ object.rack|linkify }}</td>
</tr> </tr>
</table> </table>
</div> </div>
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Reservation Details" %}</h2> <h2 class="card-header">{% trans "Reservation Details" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Units" %}</th> <th scope="row">{% trans "Units" %}</th>
<td>{{ object.unit_list }}</td> <td>{{ object.unit_list }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Tenant" %}</th> <th scope="row">{% trans "Status" %}</th>
<td> <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
{% if object.tenant.group %} </tr>
{{ object.tenant.group|linkify }} / <tr>
{% endif %} <th scope="row">{% trans "Tenant" %}</th>
{{ object.tenant|linkify|placeholder }} <td>
</td> {% if object.tenant.group %}
</tr> {{ object.tenant.group|linkify }} /
<tr> {% endif %}
<th scope="row">{% trans "User" %}</th> {{ object.tenant|linkify|placeholder }}
<td>{{ object.user }}</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "User" %}</th>
<td>{{ object.description }}</td> <td>{{ object.user }}</td>
</tr> </tr>
</table> <tr>
</div> <th scope="row">{% trans "Description" %}</th>
{% include 'inc/panels/custom_fields.html' %} <td>{{ object.description }}</td>
{% include 'inc/panels/tags.html' %} </tr>
{% include 'inc/panels/comments.html' %} </table>
{% plugin_left_page object %} </div>
</div> {% include 'inc/panels/custom_fields.html' %}
<div class="col col-12 col-xl-7"> {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-xl-7">
<div class="row" style="margin-bottom: 20px"> <div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center"> <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px"> <div style="margin-left: 30px">
<h2 class="h4">{% trans "Front" %}</h2> <h2 class="h4">{% trans "Front" %}</h2>
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %} {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
</div>
</div> </div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center"> </div>
<div style="margin-left: -30px"> <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<h2 class="h4">{% trans "Rear" %}</h2> <div style="margin-left: -30px">
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %} <h2 class="h4">{% trans "Rear" %}</h2>
</div> {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
</div> </div>
</div>
</div> </div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}