Explorar el Código

Closes #4971: Allow assigning devices to locations without a rack

Jeremy Stretch hace 5 años
padre
commit
d750b690e7

+ 7 - 1
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

+ 7 - 6
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)

+ 1 - 1
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

+ 1 - 1
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)',
     )

+ 2 - 2
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,

+ 17 - 0
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'),
+        ),
+    ]

+ 24 - 0
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
+        ),
+    ]

+ 15 - 2
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:

+ 7 - 3
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()
 
 

+ 6 - 3
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',
         )
 
 

+ 3 - 3
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)
 

+ 27 - 0
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)
 
 

+ 1 - 1
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):

+ 25 - 5
netbox/templates/dcim/device.html

@@ -18,21 +18,41 @@
                                 </div>
                                 <table class="table table-hover panel-body attr-table">
                                     <tr>
-                                        <td>Site</td>
+                                        <td>Region</td>
                                         <td>
                                             {% if object.site.region %}
-                                                <a href="{{ object.site.region.get_absolute_url }}">{{ object.site.region }}</a> /
+                                                {% for region in object.site.region.get_ancestors %}
+                                                    <a href="{{ region.get_absolute_url }}">{{ region }}</a> /
+                                                {% endfor %}
+                                                <a href="{{ object.site.region.get_absolute_url }}">{{ object.site.region }}</a>
+                                            {% else %}
+                                                <span class="text-muted">None</span>
                                             {% endif %}
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>Site</td>
+                                        <td>
                                             <a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
                                         </td>
                                     </tr>
+                                    <tr>
+                                        <td>Location</td>
+                                        <td>
+                                            {% if object.location %}
+                                                {% for location in object.location.get_ancestors %}
+                                                    <a href="{{ location.get_absolute_url }}">{{ location }}</a> /
+                                                {% endfor %}
+                                                <a href="{{ object.location.get_absolute_url }}">{{ object.location }}</a>
+                                            {% else %}
+                                                <span class="text-muted">None</span>
+                                            {% endif %}
+                                        </td>
+                                    </tr>
                                     <tr>
                                         <td>Rack</td>
                                         <td>
                                             {% if object.rack %}
-                                                {% if object.rack.group %}
-                                                    <a href="{{ object.rack.group.get_absolute_url }}">{{ object.rack.group }}</a> /
-                                                {% endif %}
                                                 <a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
                                             {% else %}
                                                 <span class="text-muted">None</span>