mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
Closes #4971: Allow assigning devices to locations without a rack
This commit is contained in:
parent
fdb3e3f9a4
commit
d750b690e7
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)',
|
||||
)
|
||||
|
@ -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
netbox/dcim/migrations/0127_device_location.py
Normal file
17
netbox/dcim/migrations/0127_device_location.py
Normal 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'),
|
||||
),
|
||||
]
|
24
netbox/dcim/migrations/0128_device_location_populate.py
Normal file
24
netbox/dcim/migrations/0128_device_location_populate.py
Normal 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
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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()
|
||||
|
||||
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user