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

This commit is contained in:
Jeremy Stretch 2021-03-03 14:28:07 -05:00
parent fdb3e3f9a4
commit d750b690e7
14 changed files with 143 additions and 28 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)',
)

View File

@ -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,

View File

@ -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'),
),
]

View File

@ -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
),
]

View File

@ -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:

View File

@ -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()

View File

@ -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',
)

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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>