diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 901a68acf..fb72c218d 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -2,5 +2,4 @@ Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. -Each location must have a name that is unique within its parent site and location, if any. - +Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 801e45b51..6b825bd45 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -18,6 +18,7 @@ * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping @@ -36,7 +37,9 @@ * dcim.DeviceType * The `u_height` field has been changed from an integer to a decimal * dcim.Interface - * Added the option `poe_mode` and `poe_type` fields + * Added the optional `poe_mode` and `poe_type` fields +* dcim.Location + * Added required `status` field (default value: `active`) * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f3d223d4c..8ac2aa738 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -151,6 +151,7 @@ class LocationSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) + status = ChoiceField(choices=LocationStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) @@ -158,8 +159,8 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 44ec3fb88..94c8b255f 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet): ] +# +# Locations +# + +class LocationStatusChoices(ChoiceSet): + key = 'Location.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_RETIRED = 'retired' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] + + # # Racks # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7c2d02bb3..628bd58f6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -216,10 +216,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label='Location (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=LocationStatusChoices, + null_value=None + ) class Meta: model = Location - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'status', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 88f043c32..b4ab226ae 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + status = forms.ChoiceField( + choices=add_blank_choice(LocationStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'tenant', 'description')), + (None, ('site', 'parent', 'status', 'tenant', 'description')), ) nullable_fields = ('parent', 'tenant', 'description') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 292f58785..d6ec0f6f4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm): 'invalid_choice': 'Location not found.', } ) + status = CSVChoiceField( + choices=LocationStatusChoices, + help_text='Operational status' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') class RackRoleCSVForm(NetBoxModelCSVForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index bdef32ec3..d9bc79fb5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF model = Location fieldsets = ( (None, ('q', 'tag')), - ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF }, label=_('Parent') ) + status = MultipleChoiceField( + choices=LocationStatusChoices, + required=False + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index c58500198..7aa2a8584 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'tags', ) + widgets = { + 'status': StaticSelect(), + } class RackRoleForm(NetBoxModelForm): diff --git a/netbox/dcim/migrations/0156_location_status.py b/netbox/dcim/migrations/0156_location_status.py new file mode 100644 index 000000000..b20273755 --- /dev/null +++ b/netbox/dcim/migrations/0156_location_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0155_interface_poe_mode_type'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index d02bd0932..9b7ffdcf4 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -341,6 +341,11 @@ class Location(NestedGroupModel): null=True, db_index=True ) + status = models.CharField( + max_length=50, + choices=LocationStatusChoices, + default=LocationStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -367,7 +372,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'tenant', 'description'] + clone_fields = ['site', 'parent', 'status', 'tenant', 'description'] class Meta: ordering = ['site', 'name'] @@ -409,6 +414,9 @@ class Location(NestedGroupModel): def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) + def get_status_color(self): + return LocationStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index fa3c73e12..83db99aec 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -126,6 +126,7 @@ class LocationTable(NetBoxTable): site = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = TenantColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', @@ -150,7 +151,7 @@ class LocationTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', - 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug', + 'contacts', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') + default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a61b44f91..436f43b6f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) parent_locations = ( - Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'), - Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'), + Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE), + Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE), ) - Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) cls.create_data = [ { @@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'slug': 'test-location-4', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 5', 'slug': 'test-location-5', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 6', 'slug': 'test-location-6', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f7d4c4e0a..9df75f4c0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'), + Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'), + Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'), + Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'), ) for location in locations: location.save() @@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9cce21a21..748bf24c8 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site, tenant=tenant), - Location(name='Location 2', slug='location-2', site=site, tenant=tenant), - Location(name='Location 3', slug='location-3', site=site, tenant=tenant), + Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), ) for location in locations: location.save() @@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'status': LocationStatusChoices.STATUS_PLANNED, 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,tenant,name,slug,description", - "Site 1,Tenant 1,Location 4,location-4,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,status,description", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) cls.bulk_edit_data = { diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b2b2bc4cd..f0335036f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -43,6 +43,10 @@ Parent {{ object.parent|linkify|placeholder }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Tenant