From bb570211973d5ba706ea2744870f8bdda803cd2e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 13:35:50 -0400 Subject: [PATCH] Closes #18984: Add status field to Rack model (#20080) --- docs/models/dcim/rackreservation.md | 7 + netbox/dcim/api/serializers_/racks.py | 24 ++- netbox/dcim/choices.py | 18 +++ netbox/dcim/filtersets.py | 4 + netbox/dcim/forms/bulk_edit.py | 8 +- netbox/dcim/forms/bulk_import.py | 7 +- netbox/dcim/forms/filtersets.py | 7 +- netbox/dcim/forms/model_forms.py | 4 +- .../migrations/0213_rackreservation_status.py | 16 ++ netbox/dcim/models/racks.py | 9 ++ netbox/dcim/tables/racks.py | 7 +- netbox/dcim/tests/test_api.py | 26 ++- netbox/dcim/tests/test_filtersets.py | 31 +++- netbox/dcim/tests/test_views.py | 10 +- netbox/templates/dcim/rackreservation.html | 148 +++++++++--------- 15 files changed, 230 insertions(+), 96 deletions(-) create mode 100644 netbox/dcim/migrations/0213_rackreservation_status.py diff --git a/docs/models/dcim/rackreservation.md b/docs/models/dcim/rackreservation.md index 32d52c9d7..8eaa11af8 100644 --- a/docs/models/dcim/rackreservation.md +++ b/docs/models/dcim/rackreservation.md @@ -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. +### 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 The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users. diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 4bc2900dc..9c2c739fe 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -137,17 +137,29 @@ class RackSerializer(RackBaseSerializer): class RackReservationSerializer(NetBoxModelSerializer): - rack = RackSerializer(nested=True) - user = UserSerializer(nested=True) - tenant = TenantSerializer(nested=True, required=False, allow_null=True) + rack = RackSerializer( + nested=True, + ) + status = ChoiceField( + choices=RackReservationStatusChoices, + required=False, + ) + user = UserSerializer( + nested=True, + ) + tenant = TenantSerializer( + nested=True, + required=False, + allow_null=True, + ) class Meta: model = RackReservation fields = [ - 'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', - 'description', 'comments', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user', + '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): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index ad96bd47c..81ea3c61a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b75febd72..4102f58d8 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='slug', label=_('Location (slug)'), ) + status = django_filters.MultipleChoiceFilter( + choices=RackReservationStatusChoices, + null_value=None + ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label=_('User (ID)'), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 587b7dbde..b7d9bcdb7 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -476,6 +476,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + label=_('Status'), + choices=add_blank_choice(RackReservationStatusChoices), + required=False, + initial='' + ) user = forms.ModelChoiceField( label=_('User'), queryset=User.objects.order_by('username'), @@ -495,7 +501,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): model = RackReservation fieldsets = ( - FieldSet('user', 'tenant', 'description'), + FieldSet('status', 'user', 'tenant', 'description'), ) nullable_fields = ('comments',) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index be47f1fc0..94e2307e0 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm): required=True, help_text=_('Comma-separated list of individual unit numbers') ) + status = CSVChoiceField( + label=_('Status'), + choices=RackReservationStatusChoices, + help_text=_('Operational status') + ) tenant = CSVModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), @@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm): class Meta: 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): super().__init__(data, *args, **kwargs) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 3c7a57546..daa3eef65 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -417,7 +417,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( 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('tenant_group_id', 'tenant_id', name=_('Tenant')), ) @@ -458,6 +458,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Rack') ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=RackReservationStatusChoices, + required=False + ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index bdaa1f0e3..02338a3b9 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -336,14 +336,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')), + FieldSet('rack', 'units', 'status', 'user', 'description', 'tags', name=_('Reservation')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = RackReservation fields = [ - 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags', + 'rack', 'units', 'status', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] diff --git a/netbox/dcim/migrations/0213_rackreservation_status.py b/netbox/dcim/migrations/0213_rackreservation_status.py new file mode 100644 index 000000000..31822912c --- /dev/null +++ b/netbox/dcim/migrations/0213_rackreservation_status.py @@ -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), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index b15cd8b34..02bce2019 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -673,6 +673,12 @@ class RackReservation(PrimaryModel): verbose_name=_('units'), base_field=models.PositiveSmallIntegerField() ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=RackReservationStatusChoices, + default=RackReservationStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -733,6 +739,9 @@ class RackReservation(PrimaryModel): def unit_list(self): return array_to_string(self.units) + def get_status_color(self): + return RackReservationStatusChoices.colors.get(self.status) + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.rack diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index ee40056de..afb2c44c8 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -229,6 +229,9 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name=_('Units') ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status'), + ) comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) @@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = RackReservation 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', ) - default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') + default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index cefbc7b52..6a819a3c0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -465,7 +465,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation - brief_fields = ['description', 'display', 'id', 'units', 'url', 'user'] + brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user'] bulk_update_data = { 'description': 'New description', } @@ -483,9 +483,24 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): Rack.objects.bulk_create(racks) rack_reservations = ( - RackReservation(rack=racks[0], 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( + rack=racks[0], + 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) @@ -493,18 +508,21 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): { 'rack': racks[1].pk, 'units': [10, 11, 12], + 'status': RackReservationStatusChoices.STATUS_ACTIVE, 'user': user.pk, 'description': 'Reservation #4', }, { 'rack': racks[1].pk, 'units': [13, 14, 15], + 'status': RackReservationStatusChoices.STATUS_PENDING, 'user': user.pk, 'description': 'Reservation #5', }, { 'rack': racks[1].pk, 'units': [16, 17, 18], + 'status': RackReservationStatusChoices.STATUS_STALE, 'user': user.pk, 'description': 'Reservation #6', }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f0701ee4b..357de1abc 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1141,9 +1141,30 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) reservations = ( - RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'), - RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'), - RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2], description='foobar3'), + RackReservation( + rack=racks[0], + 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) @@ -1179,6 +1200,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'location': [locations[0].slug, locations[1].slug]} 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): users = User.objects.all()[:2] params = {'user_id': [users[0].pk, users[1].pk]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 42a30e4f9..b23f7e16d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -337,6 +337,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'rack': rack.pk, 'units': "10,11,12", + 'status': RackReservationStatusChoices.STATUS_PENDING, 'user': user3.pk, 'tenant': None, 'description': 'Rack reservation', @@ -344,10 +345,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'site,location,rack,units,description', - 'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1', - 'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2', - 'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', + 'site,location,rack,units,status,description', + 'Site 1,Location 1,Rack 1,"10,11,12",active,Reservation 1', + 'Site 1,Location 1,Rack 1,"13,14,15",pending,Reservation 2', + 'Site 1,Location 1,Rack 1,"16,17,18",stale,Reservation 3', ) cls.csv_update_data = ( @@ -358,6 +359,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { + 'status': RackReservationStatusChoices.STATUS_STALE, 'user': user3.pk, 'tenant': None, 'description': 'New description', diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index f0f25dd83..87c4f7e4b 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -13,83 +13,87 @@ {% endblock %} {% block content %} -
-
-
-

{% trans "Rack" %}

- - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.rack.site.region %} -
{% trans "Site" %}{{ object.rack.site|linkify }}
{% trans "Location" %}{{ object.rack.location|linkify|placeholder }}
{% trans "Rack" %}{{ object.rack|linkify }}
-
-
-

{% trans "Reservation Details" %}

- - - - - - - - - - - - - - - - - -
{% trans "Units" %}{{ object.unit_list }}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "User" %}{{ object.user }}
{% trans "Description" %}{{ object.description }}
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
+
+
+
+

{% trans "Rack" %}

+ + + + + + + + + + + + + + + + + +
{% trans "Region" %} + {% nested_tree object.rack.site.region %} +
{% trans "Site" %}{{ object.rack.site|linkify }}
{% trans "Location" %}{{ object.rack.location|linkify|placeholder }}
{% trans "Rack" %}{{ object.rack|linkify }}
+
+
+

{% trans "Reservation Details" %}

+ + + + + + + + + + + + + + + + + + + + + +
{% trans "Units" %}{{ object.unit_list }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "User" %}{{ object.user }}
{% trans "Description" %}{{ object.description }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %} -
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %} -
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
+
{% plugin_right_page object %} -
-
-
+
+
+
- {% plugin_full_width_page object %} + {% plugin_full_width_page object %}
-
+
{% endblock %}