Initial work to support rackless devices

This commit is contained in:
Jeremy Stretch 2017-02-16 17:31:57 -05:00
parent 9d44d5d4e7
commit 073051bf49
13 changed files with 182 additions and 56 deletions

View File

@ -275,6 +275,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
device_role = DeviceRoleNestedSerializer() device_role = DeviceRoleNestedSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
platform = PlatformNestedSerializer() platform = PlatformNestedSerializer()
site = SiteNestedSerializer()
rack = RackNestedSerializer() rack = RackNestedSerializer()
primary_ip = DeviceIPAddressNestedSerializer() primary_ip = DeviceIPAddressNestedSerializer()
primary_ip4 = DeviceIPAddressNestedSerializer() primary_ip4 = DeviceIPAddressNestedSerializer()
@ -283,9 +284,11 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', fields = [
'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'primary_ip6', 'comments', 'custom_fields'] 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'comments', 'custom_fields',
]
def get_parent_device(self, obj): def get_parent_device(self, obj):
try: try:

View File

@ -175,12 +175,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='MAC address', label='MAC address',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site', name='site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
name='rack__site__slug', name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
@ -190,7 +190,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.all(),
label='Rack group (ID)', label='Rack group (ID)',
) )
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = NullableModelMultipleChoiceFilter(
name='rack', name='rack',
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
label='Rack (ID)', label='Rack (ID)',

View File

@ -1915,6 +1915,7 @@
"platform": 1, "platform": 1,
"name": "test1-edge1", "name": "test1-edge1",
"serial": "5555555555", "serial": "5555555555",
"site": 1,
"rack": 1, "rack": 1,
"position": 1, "position": 1,
"face": 0, "face": 0,
@ -1935,6 +1936,7 @@
"platform": 1, "platform": 1,
"name": "test1-core1", "name": "test1-core1",
"serial": "", "serial": "",
"site": 1,
"rack": 1, "rack": 1,
"position": 17, "position": 17,
"face": 0, "face": 0,
@ -1955,6 +1957,7 @@
"platform": 1, "platform": 1,
"name": "test1-spine1", "name": "test1-spine1",
"serial": "", "serial": "",
"site": 1,
"rack": 1, "rack": 1,
"position": 33, "position": 33,
"face": 0, "face": 0,
@ -1975,6 +1978,7 @@
"platform": 1, "platform": 1,
"name": "test1-leaf1", "name": "test1-leaf1",
"serial": "", "serial": "",
"site": 1,
"rack": 1, "rack": 1,
"position": 34, "position": 34,
"face": 0, "face": 0,
@ -1995,6 +1999,7 @@
"platform": 1, "platform": 1,
"name": "test1-leaf2", "name": "test1-leaf2",
"serial": "9823478293748", "serial": "9823478293748",
"site": 1,
"rack": 2, "rack": 2,
"position": 34, "position": 34,
"face": 0, "face": 0,
@ -2015,6 +2020,7 @@
"platform": 1, "platform": 1,
"name": "test1-spine2", "name": "test1-spine2",
"serial": "45649818158", "serial": "45649818158",
"site": 1,
"rack": 2, "rack": 2,
"position": 33, "position": 33,
"face": 0, "face": 0,
@ -2035,6 +2041,7 @@
"platform": 1, "platform": 1,
"name": "test1-edge2", "name": "test1-edge2",
"serial": "7567356345", "serial": "7567356345",
"site": 1,
"rack": 2, "rack": 2,
"position": 1, "position": 1,
"face": 0, "face": 0,
@ -2055,6 +2062,7 @@
"platform": 1, "platform": 1,
"name": "test1-core2", "name": "test1-core2",
"serial": "67856734534", "serial": "67856734534",
"site": 1,
"rack": 2, "rack": 2,
"position": 17, "position": 17,
"face": 0, "face": 0,
@ -2075,6 +2083,7 @@
"platform": 2, "platform": 2,
"name": "test1-oob1", "name": "test1-oob1",
"serial": "98273942938", "serial": "98273942938",
"site": 1,
"rack": 1, "rack": 1,
"position": 42, "position": 42,
"face": 0, "face": 0,
@ -2095,6 +2104,7 @@
"platform": null, "platform": null,
"name": "test1-pdu1", "name": "test1-pdu1",
"serial": "", "serial": "",
"site": 1,
"rack": 1, "rack": 1,
"position": null, "position": null,
"face": null, "face": null,
@ -2115,6 +2125,7 @@
"platform": null, "platform": null,
"name": "test1-pdu2", "name": "test1-pdu2",
"serial": "", "serial": "",
"site": 1,
"rack": 2, "rack": 2,
"position": null, "position": null,
"face": null, "face": null,

View File

@ -445,7 +445,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class DeviceForm(BootstrapMixin, CustomFieldForm): class DeviceForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect( rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}', api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name', display_field='display_name',
attrs={'filter-for': 'position'} attrs={'filter-for': 'position'}
@ -585,7 +585,7 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={ site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.', 'invalid_choice': 'Invalid site name.',
}) })
rack_name = forms.CharField() rack_name = forms.CharField(required=False)
face = forms.CharField(required=False) face = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta): class Meta(BaseDeviceFromCSVForm.Meta):

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 21:21
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0026_add_rack_reservations'),
]
operations = [
migrations.AddField(
model_name='device',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
),
]

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 21:23
from __future__ import unicode_literals
from django.db import migrations
def copy_site_from_rack(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for device in Device.objects.all():
device.site = device.rack.site
device.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0027_device_add_site'),
]
operations = [
migrations.RunPython(copy_site_from_rack),
]

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 21:25
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0028_device_copy_rack_to_site'),
]
operations = [
migrations.AlterField(
model_name='device',
name='rack',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'),
),
migrations.AlterField(
model_name='device',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
),
]

View File

@ -370,6 +370,19 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
) )
}) })
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super(Rack, self).save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
Device.objects.filter(rack=self).update(site_id=self.site.pk)
def to_csv(self): def to_csv(self):
return csv_format([ return csv_format([
self.site.name, self.site.name,
@ -871,7 +884,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
help_text='A unique tag used to identify this device') help_text='A unique tag used to identify this device')
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT) site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
verbose_name='Position (U)', verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device') help_text='The lowest-numbered unit occupied by the device')
@ -898,41 +912,59 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def clean(self): def clean(self):
# Validate site/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
})
if self.rack is None:
if self.face is not None:
raise ValidationError({
'face': "Cannot select a rack face without assigning a rack.",
})
if self.position:
raise ValidationError({
'face': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination # Validate position/face combination
if self.position and self.face is None: if self.position and self.face is None:
raise ValidationError({ raise ValidationError({
'face': "Must specify rack face when defining rack position." 'face': "Must specify rack face when defining rack position.",
}) })
try: if self.rack:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
raise ValidationError({
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
})
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try: try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face, # Child devices cannot be assigned to a rack face/unit
exclude=exclude_list) if self.device_type.is_child_device and self.face is not None:
if self.position and self.position not in available_units:
raise ValidationError({ raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} " 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"({}U).".format(self.position, self.device_type, self.device_type.u_height) "device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
}) })
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist: # Validate rack space
pass rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
})
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -980,8 +1012,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.platform.name if self.platform else None, self.platform.name if self.platform else None,
self.serial, self.serial,
self.asset_tag, self.asset_tag,
self.rack.site.name, self.site.name,
self.rack.name, self.rack.name if self.rack else None,
self.position, self.position,
self.get_face_display(), self.get_face_display(),
]) ])

View File

@ -311,8 +311,7 @@ class DeviceTable(BaseTable):
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')], site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
@ -328,8 +327,7 @@ class DeviceTable(BaseTable):
class DeviceImportTable(BaseTable): class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')], site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position') position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role') device_role = tables.Column(verbose_name='Role')

View File

@ -346,6 +346,7 @@ class DeviceTest(APITestCase):
'platform', 'platform',
'serial', 'serial',
'asset_tag', 'asset_tag',
'site',
'rack', 'rack',
'position', 'position',
'face', 'face',
@ -417,6 +418,9 @@ class DeviceTest(APITestCase):
'primary_ip4_family', 'primary_ip4_family',
'primary_ip4_id', 'primary_ip4_id',
'primary_ip6', 'primary_ip6',
'site_id',
'site_name',
'site_slug',
'rack_display_name', 'rack_display_name',
'rack_facility_id', 'rack_facility_id',
'rack_id', 'rack_id',

View File

@ -627,7 +627,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class DeviceListView(ObjectListView): class DeviceListView(ObjectListView):
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site', queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack',
'primary_ip4', 'primary_ip6') 'primary_ip4', 'primary_ip6')
filter = filters.DeviceFilter filter = filters.DeviceFilter
filter_form = forms.DeviceFilterForm filter_form = forms.DeviceFilterForm

View File

@ -27,13 +27,17 @@
<tr> <tr>
<td>Site</td> <td>Site</td>
<td> <td>
<a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a> <a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Rack</td> <td>Rack</td>
<td> <td>
<span><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}</span> {% if device.rack %}
<span><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}</span>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
@ -44,9 +48,9 @@
<span>U{{ parent.position }} / {{ parent.get_face_display }} <span>U{{ parent.position }} / {{ parent.get_face_display }}
(<a href="{{ parent.get_absolute_url }}">{{ parent }}</a> - {{ device.parent_bay.name }})</span> (<a href="{{ parent.get_absolute_url }}">{{ parent }}</a> - {{ device.parent_bay.name }})</span>
{% endwith %} {% endwith %}
{% elif device.position %} {% elif device.rack and device.position %}
<span>U{{ device.position }} / {{ device.get_face_display }}</span> <span>U{{ device.position }} / {{ device.get_face_display }}</span>
{% elif device.device_type.u_height %} {% elif device.rack and device.device_type.u_height %}
<span class="label label-warning">Not racked</span> <span class="label label-warning">Not racked</span>
{% else %} {% else %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
@ -314,7 +318,11 @@
<a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a> <a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
</td> </td>
<td> <td>
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a> {% if rd.rack %}
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td> </td>
<td>{{ rd.device_type.full_name }}</td> <td>{{ rd.device_type.full_name }}</td>
</tr> </tr>

View File

@ -1,17 +1,17 @@
<div class="row"> <div class="row">
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
{% if device.rack %} <ol class="breadcrumb">
<ol class="breadcrumb"> <li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
<li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li> {% if device.rack %}
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.rack.site.slug }}">Racks</a></li> <li><a href="{% url 'dcim:rack_list' %}?site={{ device.rack.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li> <li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% if device.parent_bay %} {% endif %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li> {% if device.parent_bay %}
<li>{{ device.parent_bay.name }}</li> <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
{% endif %} <li>{{ device.parent_bay.name }}</li>
<li>{{ device }}</li> {% endif %}
</ol> <li>{{ device }}</li>
{% endif %} </ol>
</div> </div>
<div class="col-sm-4 col-md-3"> <div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:device_list' %}" method="get"> <form action="{% url 'dcim:device_list' %}" method="get">