From d750b690e7adbd0be5d74db60107733e3eb5c1f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Mar 2021 14:28:07 -0500 Subject: [PATCH] Closes #4971: Allow assigning devices to locations without a rack --- docs/release-notes/version-2.11.md | 8 ++++- netbox/dcim/api/serializers.py | 13 ++++---- netbox/dcim/api/views.py | 2 +- netbox/dcim/filters.py | 2 +- netbox/dcim/forms.py | 4 +-- .../dcim/migrations/0127_device_location.py | 17 +++++++++++ .../0128_device_location_populate.py | 24 +++++++++++++++ netbox/dcim/models/devices.py | 17 +++++++++-- netbox/dcim/signals.py | 10 +++++-- netbox/dcim/tables/devices.py | 9 ++++-- netbox/dcim/tests/test_filters.py | 6 ++-- netbox/dcim/tests/test_models.py | 27 +++++++++++++++++ netbox/dcim/views.py | 2 +- netbox/templates/dcim/device.html | 30 +++++++++++++++---- 14 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 netbox/dcim/migrations/0127_device_location.py create mode 100644 netbox/dcim/migrations/0128_device_location_populate.py diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index d46893a9b..7c515cae3 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -10,7 +10,11 @@ Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination. -In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. +In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. + +#### Allow Assigning Devices to Locations ([#4971](https://github.com/netbox-community/netbox/issues/4971)) + +Devices can now be assigned to locations (formerly known as rack groups) within a site without needing to be assigned to a particular rack. This is handy for assigning devices to rooms or floors within a building where racks are not used. The `location` foreign key field has been added to the Device model to support this. ### Enhancements @@ -42,6 +46,8 @@ In addition to the new `mark_connected` boolean field, the REST API representati * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied * Renamed RackGroup to Location * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` +* dcim.Device + * Added the `location` field * dcim.PowerPanel * Renamed `rack_group` field to `location` * dcim.Rack diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c84de5e5e..469cea2a5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -433,6 +433,7 @@ class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() + location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) @@ -447,9 +448,9 @@ class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): model = Device fields = [ 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', + 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -483,9 +484,9 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class Meta(DeviceSerializer.Meta): fields = [ 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', + 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2a93d2509..a05076591 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -345,7 +345,7 @@ class PlatformViewSet(CustomFieldModelViewSet): class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): queryset = Device.objects.prefetch_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', + 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filters.DeviceFilterSet diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d0cbbfbe5..d7db93666 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -577,7 +577,7 @@ class DeviceFilterSet( ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), - field_name='rack__location', + field_name='location', lookup_expr='in', label='Location (ID)', ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9fab6c6c2..9a195b75e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2009,13 +2009,13 @@ class DeviceCSVForm(BaseDeviceCSVForm): queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's location (if any)" + help_text="Assigned location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text="Assigned rack" + help_text="Assigned rack (if any)" ) face = CSVChoiceField( choices=DeviceFaceChoices, diff --git a/netbox/dcim/migrations/0127_device_location.py b/netbox/dcim/migrations/0127_device_location.py new file mode 100644 index 000000000..479f9cea9 --- /dev/null +++ b/netbox/dcim/migrations/0127_device_location.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0126_rename_rackgroup_location'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.location'), + ), + ] diff --git a/netbox/dcim/migrations/0128_device_location_populate.py b/netbox/dcim/migrations/0128_device_location_populate.py new file mode 100644 index 000000000..06a172ac3 --- /dev/null +++ b/netbox/dcim/migrations/0128_device_location_populate.py @@ -0,0 +1,24 @@ +from django.db import migrations +from django.db.models import Subquery, OuterRef + + +def populate_device_location(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + Device.objects.filter(rack__isnull=False).update( + location_id=Subquery( + Device.objects.filter(pk=OuterRef('pk')).values('rack__location_id')[:1] + ) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0127_device_location'), + ] + + operations = [ + migrations.RunPython( + code=populate_device_location + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 843d1d660..54a34aa57 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -517,6 +517,13 @@ class Device(PrimaryModel, ConfigContextModel): on_delete=models.PROTECT, related_name='devices' ) + location = models.ForeignKey( + to='dcim.Location', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) rack = models.ForeignKey( to='dcim.Rack', on_delete=models.PROTECT, @@ -603,7 +610,7 @@ class Device(PrimaryModel, ConfigContextModel): 'site', 'location', 'rack_name', 'position', 'face', 'comments', ] clone_fields = [ - 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', + 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster', ] class Meta: @@ -640,11 +647,17 @@ class Device(PrimaryModel, ConfigContextModel): def clean(self): super().clean() - # Validate site/rack combination + # Validate site/location/rack combination if self.rack and self.site != self.rack.site: raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to site {self.site}.", }) + if self.rack and self.location and self.rack.location != self.location: + raise ValidationError({ + 'rack': f"Rack {self.rack} does not belong to location {self.location}.", + }) + elif self.rack: + self.location = self.rack.location if self.rack is None: if self.face: diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 2a4107339..212c9b97c 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -43,7 +43,7 @@ def rebuild_paths(obj): @receiver(post_save, sender=Location) def handle_location_site_change(instance, created, **kwargs): """ - Update child Locations and Racks if Site assignment has changed. We intentionally recurse through each child + Update child objects if Site assignment has changed. We intentionally recurse through each child object instead of calling update() on the QuerySet to ensure the proper change records get created for each. """ if not created: @@ -53,6 +53,9 @@ def handle_location_site_change(instance, created, **kwargs): for rack in Rack.objects.filter(location=instance).exclude(site=instance.site): rack.site = instance.site rack.save() + for device in Device.objects.filter(location=instance).exclude(site=instance.site): + device.site = instance.site + device.save() for powerpanel in PowerPanel.objects.filter(location=instance).exclude(site=instance.site): powerpanel.site = instance.site powerpanel.save() @@ -61,11 +64,12 @@ def handle_location_site_change(instance, created, **kwargs): @receiver(post_save, sender=Rack) def handle_rack_site_change(instance, created, **kwargs): """ - Update child Devices if Site assignment has changed. + Update child Devices if Site or Location assignment has changed. """ if not created: - for device in Device.objects.filter(rack=instance).exclude(site=instance.site): + for device in Device.objects.filter(rack=instance).exclude(site=instance.site, location=instance.location): device.site = instance.site + device.location = instance.location device.save() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index ba75df3a8..c844bb004 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -115,6 +115,9 @@ class DeviceTable(BaseTable): site = tables.Column( linkify=True ) + location = tables.Column( + linkify=True + ) rack = tables.Column( linkify=True ) @@ -162,11 +165,11 @@ class DeviceTable(BaseTable): model = Device fields = ( 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', - 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', - 'vc_position', 'vc_priority', 'tags', + 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', + 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', ) default_columns = ( - 'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip', + 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip', ) diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 3f65179ef..aa76bdc64 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1207,9 +1207,9 @@ class DeviceTestCase(TestCase): Tenant.objects.bulk_create(tenants) devices = ( - Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), ) Device.objects.bulk_create(devices) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 5bcff1b6b..815d86758 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -16,8 +16,18 @@ class LocationTestCase(TestCase): - Location A1 - Location A2 - Rack 2 + - Device 2 - Rack 1 + - Device 1 """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + device_role = DeviceRole.objects.create( + name='Device Role 1', slug='device-role-1', color='ff0000' + ) + site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') @@ -29,6 +39,21 @@ class LocationTestCase(TestCase): rack1 = Rack.objects.create(site=site_a, location=location_a1, name='Rack 1') rack2 = Rack.objects.create(site=site_a, location=location_a2, name='Rack 2') + device1 = Device.objects.create( + site=site_a, + location=location_a1, + name='Device 1', + device_type=device_type, + device_role=device_role + ) + device2 = Device.objects.create( + site=site_a, + location=location_a2, + name='Device 2', + device_type=device_type, + device_role=device_role + ) + powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1') # Move Location A1 to Site B @@ -40,6 +65,8 @@ class LocationTestCase(TestCase): self.assertEqual(Location.objects.get(pk=location_a2.pk).site, site_b) self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b) self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b) + self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b) + self.assertEqual(Device.objects.get(pk=device2.pk).site, site_b) self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7d233b2f0..4c76e16a6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -982,7 +982,7 @@ class DeviceListView(generic.ObjectListView): class DeviceView(generic.ObjectView): queryset = Device.objects.prefetch_related( - 'site__region', 'rack__location', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' + 'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' ) def get_extra_context(self, request, instance): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 917384b98..81d8bd9b3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -18,21 +18,41 @@ - + + + + + + + + +
SiteRegion {% if object.site.region %} - {{ object.site.region }} / + {% for region in object.site.region.get_ancestors %} + {{ region }} / + {% endfor %} + {{ object.site.region }} + {% else %} + None {% endif %} +
Site {{ object.site }}
Location + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / + {% endfor %} + {{ object.location }} + {% else %} + None + {% endif %} +
Rack {% if object.rack %} - {% if object.rack.group %} - {{ object.rack.group }} / - {% endif %} {{ object.rack }} {% else %} None