Browse Source

Merge pull request #197 from digitalocean/device-bays

Device bays
Jeremy Stretch 8 years ago
parent
commit
1b5231c188

+ 7 - 3
docs/dcim.md

@@ -43,6 +43,7 @@ Each device type is assigned a number of component templates which describe the
 * Power port templates
 * Power port templates
 * Power outlet templates
 * Power outlet templates
 * Interface templates
 * Interface templates
+* Device bay templates
 
 
 Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it:
 Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it:
 
 
@@ -81,16 +82,19 @@ A device can be assigned modules which represent internal components. Currently,
 
 
 ### Components
 ### Components
 
 
-There are five types of device components which comprise all of the interconnection logic with NetBox:
+There are six types of device components which comprise all of the interconnection logic with NetBox:
 
 
 * Console ports
 * Console ports
 * Console server ports
 * Console server ports
 * Power ports
 * Power ports
 * Power outlets
 * Power outlets
 * Interfaces
 * Interfaces
+* Device bays
 
 
 Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.)
 Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.)
 
 
-Each type of connection can be defined as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed.
+Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
 
 
-In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
+Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
+
+Note that child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply.

File diff suppressed because it is too large
+ 0 - 3914
docs/schema.sql


+ 20 - 4
netbox/dcim/admin.py

@@ -2,9 +2,9 @@ from django.contrib import admin
 from django.db.models import Count
 from django.db.models import Count
 
 
 from .models import (
 from .models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
-    Interface, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, Site,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
+    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
 )
 )
 
 
 
 
@@ -61,6 +61,10 @@ class InterfaceTemplateAdmin(admin.TabularInline):
     model = InterfaceTemplate
     model = InterfaceTemplate
 
 
 
 
+class DeviceBayTemplateAdmin(admin.TabularInline):
+    model = DeviceBayTemplate
+
+
 @admin.register(DeviceType)
 @admin.register(DeviceType)
 class DeviceTypeAdmin(admin.ModelAdmin):
 class DeviceTypeAdmin(admin.ModelAdmin):
     prepopulated_fields = {
     prepopulated_fields = {
@@ -72,9 +76,10 @@ class DeviceTypeAdmin(admin.ModelAdmin):
         PowerPortTemplateAdmin,
         PowerPortTemplateAdmin,
         PowerOutletTemplateAdmin,
         PowerOutletTemplateAdmin,
         InterfaceTemplateAdmin,
         InterfaceTemplateAdmin,
+        DeviceBayTemplateAdmin,
     ]
     ]
     list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
     list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
-                    'power_outlets', 'interfaces']
+                    'power_outlets', 'interfaces', 'device_bays']
     list_filter = ['manufacturer']
     list_filter = ['manufacturer']
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
@@ -84,6 +89,7 @@ class DeviceTypeAdmin(admin.ModelAdmin):
             power_port_count=Count('power_port_templates', distinct=True),
             power_port_count=Count('power_port_templates', distinct=True),
             power_outlet_count=Count('power_outlet_templates', distinct=True),
             power_outlet_count=Count('power_outlet_templates', distinct=True),
             interface_count=Count('interface_templates', distinct=True),
             interface_count=Count('interface_templates', distinct=True),
+            devicebay_count=Count('devicebay_templates', distinct=True),
         )
         )
 
 
     def console_ports(self, instance):
     def console_ports(self, instance):
@@ -101,6 +107,9 @@ class DeviceTypeAdmin(admin.ModelAdmin):
     def interfaces(self, instance):
     def interfaces(self, instance):
         return instance.interface_count
         return instance.interface_count
 
 
+    def device_bays(self, instance):
+        return instance.devicebay_count
+
 
 
 #
 #
 # Devices
 # Devices
@@ -144,6 +153,12 @@ class InterfaceAdmin(admin.TabularInline):
     model = Interface
     model = Interface
 
 
 
 
+class DeviceBayAdmin(admin.TabularInline):
+    model = DeviceBay
+    fk_name = 'device'
+    readonly_fields = ['installed_device']
+
+
 class ModuleAdmin(admin.TabularInline):
 class ModuleAdmin(admin.TabularInline):
     model = Module
     model = Module
     readonly_fields = ['parent', 'discovered']
     readonly_fields = ['parent', 'discovered']
@@ -157,6 +172,7 @@ class DeviceAdmin(admin.ModelAdmin):
         PowerPortAdmin,
         PowerPortAdmin,
         PowerOutletAdmin,
         PowerOutletAdmin,
         InterfaceAdmin,
         InterfaceAdmin,
+        DeviceBayAdmin,
         ModuleAdmin,
         ModuleAdmin,
     ]
     ]
     list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
     list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']

+ 47 - 6
netbox/dcim/api/serializers.py

@@ -2,9 +2,9 @@ from rest_framework import serializers
 
 
 from ipam.models import IPAddress
 from ipam.models import IPAddress
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceType, DeviceRole,
-    Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate,
-    PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
+    DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
+    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
 )
 )
 
 
 
 
@@ -221,16 +221,31 @@ class DeviceSerializer(serializers.ModelSerializer):
     platform = PlatformNestedSerializer()
     platform = PlatformNestedSerializer()
     rack = RackNestedSerializer()
     rack = RackNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
+    parent_device = serializers.SerializerMethodField()
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
         fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
-                  'face', 'status', 'primary_ip', 'comments']
+                  'face', 'parent_device', 'status', 'primary_ip', 'comments']
 
 
+    def get_parent_device(self, obj):
+        try:
+            device_bay = obj.parent_bay
+        except DeviceBay.DoesNotExist:
+            return None
+        return {
+            'id': device_bay.device.pk,
+            'name': device_bay.device.name,
+            'device_bay': {
+                'id': device_bay.pk,
+                'name': device_bay.name,
+            }
+        }
 
 
-class DeviceNestedSerializer(DeviceSerializer):
 
 
-    class Meta(DeviceSerializer.Meta):
+class DeviceNestedSerializer(serializers.ModelSerializer):
+
+    class Meta:
         model = Device
         model = Device
         fields = ['id', 'name', 'display_name']
         fields = ['id', 'name', 'display_name']
 
 
@@ -338,6 +353,32 @@ class InterfaceDetailSerializer(InterfaceSerializer):
 
 
 
 
 #
 #
+# Device bays
+#
+
+class DeviceBaySerializer(serializers.ModelSerializer):
+    device = DeviceNestedSerializer()
+
+    class Meta:
+        model = DeviceBay
+        fields = ['id', 'device', 'name']
+
+
+class DeviceBayNestedSerializer(DeviceBaySerializer):
+    installed_device = DeviceNestedSerializer()
+
+    class Meta(DeviceBaySerializer.Meta):
+        fields = ['id', 'name', 'installed_device']
+
+
+class DeviceBayDetailSerializer(DeviceBaySerializer):
+    installed_device = DeviceNestedSerializer()
+
+    class Meta(DeviceBaySerializer.Meta):
+        fields = ['id', 'device', 'name', 'installed_device']
+
+
+#
 # Interface connections
 # Interface connections
 #
 #
 
 

+ 1 - 0
netbox/dcim/api/urls.py

@@ -49,6 +49,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
     url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
     url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
     url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
+    url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
 
 
     # Console ports
     # Console ports
     url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
     url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),

+ 29 - 2
netbox/dcim/api/views.py

@@ -9,8 +9,8 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 
 
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, InterfaceConnection,
-    Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
+    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
+    InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
 )
 )
 from dcim import filters
 from dcim import filters
 from .exceptions import MissingFilterException
 from .exceptions import MissingFilterException
@@ -327,6 +327,33 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
 
 
 
 
 #
 #
+# Device bays
+#
+
+class DeviceBayListView(generics.ListAPIView):
+    """
+    List device bays (by device)
+    """
+    serializer_class = serializers.DeviceBayNestedSerializer
+
+    def get_queryset(self):
+
+        device = get_object_or_404(Device, pk=self.kwargs['pk'])
+        queryset = DeviceBay.objects.filter(device=device).select_related('installed_device')
+
+        # Filter by type (physical or virtual)
+        iface_type = self.request.query_params.get('type')
+        if iface_type == 'physical':
+            queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
+        elif iface_type == 'virtual':
+            queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
+        elif iface_type is not None:
+            queryset = queryset.empty()
+
+        return queryset
+
+
+#
 # Live queries
 # Live queries
 #
 #
 
 

+ 48 - 5
netbox/dcim/forms.py

@@ -10,10 +10,10 @@ from utilities.forms import (
 )
 )
 
 
 from .models import (
 from .models import (
-    CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate,
-    ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_VIRTUAL,
-    InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES
+    DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
+    Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
+    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
 )
 )
 
 
 
 
@@ -216,7 +216,7 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = ['manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
         fields = ['manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-                  'is_network_device']
+                  'is_network_device', 'subdevice_role']
 
 
 
 
 class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
 class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
@@ -283,6 +283,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
         fields = ['name_pattern', 'form_factor', 'mgmt_only']
         fields = ['name_pattern', 'form_factor', 'mgmt_only']
 
 
 
 
+class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
+    name_pattern = ExpandableNameField(label='Name')
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = ['name_pattern']
+
+
 #
 #
 # Device roles
 # Device roles
 #
 #
@@ -1081,6 +1089,41 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
 
 
 
 
 #
 #
+# Device bays
+#
+
+class DeviceBayForm(forms.ModelForm, BootstrapMixin):
+
+    class Meta:
+        model = DeviceBay
+        fields = ['device', 'name']
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class DeviceBayCreateForm(forms.Form, BootstrapMixin):
+    name_pattern = ExpandableNameField(label='Name')
+
+
+class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
+    installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
+                                              help_text="Child devices must first be created within the rack occupied "
+                                                        "by the parent device. Then they can be assigned to a bay.")
+
+    def __init__(self, device_bay, *args, **kwargs):
+
+        super(PopulateDeviceBayForm, self).__init__(*args, **kwargs)
+
+        children_queryset = Device.objects.filter(rack=device_bay.device.rack,
+                                                  parent_bay__isnull=True,
+                                                  device_type__u_height=0,
+                                                  device_type__subdevice_role=SUBDEVICE_ROLE_CHILD)\
+            .exclude(pk=device_bay.device.pk)
+        self.fields['installed_device'].queryset = children_queryset
+
+
+#
 # Connections
 # Connections
 #
 #
 
 

+ 56 - 0
netbox/dcim/migrations/0004_auto_20160701_2049.py

@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-01 20:49
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0003_auto_20160628_1721'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DeviceBay',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, verbose_name=b'Name')),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bays', to='dcim.Device')),
+                ('installed_device', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_bay', to='dcim.Device')),
+            ],
+            options={
+                'ordering': ['device', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='DeviceBayTemplate',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=30)),
+            ],
+            options={
+                'ordering': ['device_type', 'name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='subdevice_role',
+            field=models.NullBooleanField(choices=[(None, b'N/A'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'),
+        ),
+        migrations.AddField(
+            model_name='devicebaytemplate',
+            name='device_type',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bay_templates', to='dcim.DeviceType'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='devicebaytemplate',
+            unique_together=set([('device_type', 'name')]),
+        ),
+        migrations.AlterUniqueTogether(
+            name='devicebay',
+            unique_together=set([('device', 'name')]),
+        ),
+    ]

+ 84 - 2
netbox/dcim/models.py

@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.core.validators import MinValueValidator
 from django.core.validators import MinValueValidator
 from django.db import models
 from django.db import models
-from django.db.models import Q, ObjectDoesNotExist
+from django.db.models import Count, Q, ObjectDoesNotExist
 
 
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from utilities.fields import NullableCharField
 from utilities.fields import NullableCharField
@@ -18,6 +18,14 @@ RACK_FACE_CHOICES = [
     [RACK_FACE_REAR, 'Rear'],
     [RACK_FACE_REAR, 'Rear'],
 ]
 ]
 
 
+SUBDEVICE_ROLE_PARENT = True
+SUBDEVICE_ROLE_CHILD = False
+SUBDEVICE_ROLE_CHOICES = (
+    (None, 'None'),
+    (SUBDEVICE_ROLE_PARENT, 'Parent'),
+    (SUBDEVICE_ROLE_CHILD, 'Child'),
+)
+
 COLOR_TEAL = 'teal'
 COLOR_TEAL = 'teal'
 COLOR_GREEN = 'green'
 COLOR_GREEN = 'green'
 COLOR_BLUE = 'blue'
 COLOR_BLUE = 'blue'
@@ -274,6 +282,7 @@ class Rack(CreatedUpdatedModel):
         # Add devices to rack units list
         # Add devices to rack units list
         if self.pk:
         if self.pk:
             for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
             for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
+                    .annotate(devicebay_count=Count('device_bays'))\
                     .exclude(pk=exclude)\
                     .exclude(pk=exclude)\
                     .filter(rack=self, position__gt=0)\
                     .filter(rack=self, position__gt=0)\
                     .filter(Q(face=face) | Q(device_type__is_full_depth=True)):
                     .filter(Q(face=face) | Q(device_type__is_full_depth=True)):
@@ -380,6 +389,10 @@ class DeviceType(models.Model):
                                  help_text="This type of device has power outlets")
                                  help_text="This type of device has power outlets")
     is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
     is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
                                             help_text="This type of device has network interfaces")
                                             help_text="This type of device has network interfaces")
+    subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status',
+                                             choices=SUBDEVICE_ROLE_CHOICES,
+                                             help_text="Parent devices house child devices in device bays. Select "
+                                                       "\"None\" if this device type is neither a parent nor a child.")
 
 
     class Meta:
     class Meta:
         ordering = ['manufacturer', 'model']
         ordering = ['manufacturer', 'model']
@@ -389,11 +402,24 @@ class DeviceType(models.Model):
         ]
         ]
 
 
     def __unicode__(self):
     def __unicode__(self):
-        return "{0} {1}".format(self.manufacturer, self.model)
+        return "{} {}".format(self.manufacturer, self.model)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
         return reverse('dcim:devicetype', args=[self.pk])
 
 
+    def clean(self):
+
+        if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
+            raise ValidationError("Child device types must be 0U.")
+
+    @property
+    def is_parent_device(self):
+        return bool(self.subdevice_role)
+
+    @property
+    def is_child_device(self):
+        return bool(self.subdevice_role is False)
+
 
 
 class ConsolePortTemplate(models.Model):
 class ConsolePortTemplate(models.Model):
     """
     """
@@ -481,6 +507,21 @@ class InterfaceTemplate(models.Model):
         return self.name
         return self.name
 
 
 
 
+class DeviceBayTemplate(models.Model):
+    """
+    A template for a DeviceBay to be created for a new parent Device.
+    """
+    device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE)
+    name = models.CharField(max_length=30)
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __unicode__(self):
+        return self.name
+
+
 #
 #
 # Devices
 # Devices
 #
 #
@@ -563,6 +604,10 @@ class Device(CreatedUpdatedModel):
 
 
     def clean(self):
     def clean(self):
 
 
+        # Child devices cannot be assigned to a rack face/unit
+        if self.device_type.is_child_device and (self.face is not None or self.position):
+            raise ValidationError("Child device types cannot be assigned a rack face or position.")
+
         # 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("Must specify rack face with rack position.")
             raise ValidationError("Must specify rack face with rack position.")
@@ -610,6 +655,10 @@ class Device(CreatedUpdatedModel):
                 [Interface(device=self, name=template.name, form_factor=template.form_factor,
                 [Interface(device=self, name=template.name, form_factor=template.form_factor,
                            mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
                            mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
             )
             )
+            DeviceBay.objects.bulk_create(
+                [DeviceBay(device=self, name=template.name) for template in
+                 self.device_type.device_bay_templates.all()]
+            )
 
 
     def to_csv(self):
     def to_csv(self):
         return ','.join([
         return ','.join([
@@ -643,6 +692,12 @@ class Device(CreatedUpdatedModel):
             return self.name
             return self.name
         return '{{{}}}'.format(self.pk)
         return '{{{}}}'.format(self.pk)
 
 
+    def get_children(self):
+        """
+        Return the set of child Devices installed in DeviceBays within this Device.
+        """
+        return Device.objects.filter(parent_bay__device=self.pk)
+
     def get_rpc_client(self):
     def get_rpc_client(self):
         """
         """
         Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
         Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
@@ -860,6 +915,33 @@ class InterfaceConnection(models.Model):
         ])
         ])
 
 
 
 
+class DeviceBay(models.Model):
+    """
+    An empty space within a Device which can house a child device
+    """
+    device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
+    name = models.CharField(max_length=50, verbose_name='Name')
+    installed_device = models.OneToOneField('Device', related_name='parent_bay', blank=True, null=True)
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __unicode__(self):
+        return '{} - {}'.format(self.device.name, self.name)
+
+    def clean(self):
+
+        # Validate that the parent Device can have DeviceBays
+        if not self.device.device_type.is_parent_device:
+            raise ValidationError("This type of device ({}) does not support device bays."
+                                  .format(self.device.device_type))
+
+        # Cannot install a device into itself, obviously
+        if self.device == self.installed_device:
+            raise ValidationError("Cannot install a device into itself.")
+
+
 class Module(models.Model):
 class Module(models.Model):
     """
     """
     A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
     A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only

+ 16 - 2
netbox/dcim/tables.py

@@ -4,8 +4,9 @@ from django_tables2.utils import Accessor
 from utilities.tables import BaseTable, ToggleColumn
 from utilities.tables import BaseTable, ToggleColumn
 
 
 from .models import (
 from .models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, InterfaceTemplate,
-    Interface, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
+    Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
+    RackGroup, Site,
 )
 )
 
 
 
 
@@ -201,6 +202,19 @@ class InterfaceTemplateTable(tables.Table):
         }
         }
 
 
 
 
+class DeviceBayTemplateTable(tables.Table):
+    pk = ToggleColumn()
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = ('pk', 'name')
+        empty_text = "None"
+        show_header = False
+        attrs = {
+            'class': 'table table-hover panel-body',
+        }
+
+
 #
 #
 # Device roles
 # Device roles
 #
 #

+ 2 - 0
netbox/dcim/tests/test_apis.py

@@ -315,6 +315,7 @@ class DeviceTest(APITestCase):
         'rack',
         'rack',
         'position',
         'position',
         'face',
         'face',
+        'parent_device',
         'status',
         'status',
         'primary_ip',
         'primary_ip',
         'comments',
         'comments',
@@ -366,6 +367,7 @@ class DeviceTest(APITestCase):
             'face',
             'face',
             'id',
             'id',
             'name',
             'name',
+            'parent_device',
             'platform_id',
             'platform_id',
             'platform_name',
             'platform_name',
             'platform_slug',
             'platform_slug',

+ 13 - 1
netbox/dcim/urls.py

@@ -4,7 +4,8 @@ from secrets.views import secret_add
 
 
 from . import views
 from . import views
 from .models import (
 from .models import (
-    ConsolePortTemplate, ConsoleServerPortTemplate, PowerPortTemplate, PowerOutletTemplate, InterfaceTemplate,
+    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
+    InterfaceTemplate,
 )
 )
 
 
 
 
@@ -70,6 +71,10 @@ urlpatterns = [
         name='devicetype_add_interface'),
         name='devicetype_add_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.component_template_delete,
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.component_template_delete,
         {'model': InterfaceTemplate}, name='devicetype_delete_interface'),
         {'model': InterfaceTemplate}, name='devicetype_delete_interface'),
+    url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(),
+        name='devicetype_add_devicebay'),
+    url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.component_template_delete,
+        {'model': DeviceBayTemplate}, name='devicetype_delete_devicebay'),
 
 
     # Device roles
     # Device roles
     url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
@@ -125,6 +130,13 @@ urlpatterns = [
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
 
 
+    # Device bays
+    url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
+    url(r'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'),
+    url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
+    url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
+    url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
+
     # Console/power/interface connections
     # Console/power/interface connections
     url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
     url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
     url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
     url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),

+ 153 - 4
netbox/dcim/views.py

@@ -24,8 +24,9 @@ from utilities.views import (
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .models import (
 from .models import (
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
-    DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform,
-    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
+    DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
+    Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
+    Site,
 )
 )
 
 
 
 
@@ -153,7 +154,8 @@ def rack(request, pk):
 
 
     rack = get_object_or_404(Rack, pk=pk)
     rack = get_object_or_404(Rack, pk=pk)
 
 
-    nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)
+    nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)\
+        .select_related('device_type__manufacturer')
     next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
     next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
     prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
     prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
 
 
@@ -263,12 +265,14 @@ def devicetype(request, pk):
     powerport_table = tables.PowerPortTemplateTable(PowerPortTemplate.objects.filter(device_type=devicetype))
     powerport_table = tables.PowerPortTemplateTable(PowerPortTemplate.objects.filter(device_type=devicetype))
     poweroutlet_table = tables.PowerOutletTemplateTable(PowerOutletTemplate.objects.filter(device_type=devicetype))
     poweroutlet_table = tables.PowerOutletTemplateTable(PowerOutletTemplate.objects.filter(device_type=devicetype))
     interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype))
     interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype))
+    devicebay_table = tables.DeviceBayTemplateTable(DeviceBayTemplate.objects.filter(device_type=devicetype))
     if request.user.has_perm('dcim.change_devicetype'):
     if request.user.has_perm('dcim.change_devicetype'):
         consoleport_table.base_columns['pk'].visible = True
         consoleport_table.base_columns['pk'].visible = True
         consoleserverport_table.base_columns['pk'].visible = True
         consoleserverport_table.base_columns['pk'].visible = True
         powerport_table.base_columns['pk'].visible = True
         powerport_table.base_columns['pk'].visible = True
         poweroutlet_table.base_columns['pk'].visible = True
         poweroutlet_table.base_columns['pk'].visible = True
         interface_table.base_columns['pk'].visible = True
         interface_table.base_columns['pk'].visible = True
+        devicebay_table.base_columns['pk'].visible = True
 
 
     return render(request, 'dcim/devicetype.html', {
     return render(request, 'dcim/devicetype.html', {
         'devicetype': devicetype,
         'devicetype': devicetype,
@@ -277,6 +281,7 @@ def devicetype(request, pk):
         'powerport_table': powerport_table,
         'powerport_table': powerport_table,
         'poweroutlet_table': poweroutlet_table,
         'poweroutlet_table': poweroutlet_table,
         'interface_table': interface_table,
         'interface_table': interface_table,
+        'devicebay_table': devicebay_table,
     })
     })
 
 
 
 
@@ -395,6 +400,11 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
     form = forms.InterfaceTemplateForm
     form = forms.InterfaceTemplateForm
 
 
 
 
+class DeviceBayTemplateAddView(ComponentTemplateCreateView):
+    model = DeviceBayTemplate
+    form = forms.DeviceBayTemplateForm
+
+
 def component_template_delete(request, pk, model):
 def component_template_delete(request, pk, model):
 
 
     devicetype = get_object_or_404(DeviceType, pk=pk)
     devicetype = get_object_or_404(DeviceType, pk=pk)
@@ -510,6 +520,7 @@ def device(request, pk):
         .select_related('connected_as_a', 'connected_as_b', 'circuit')
         .select_related('connected_as_a', 'connected_as_b', 'circuit')
     mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
     mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
         .select_related('connected_as_a', 'connected_as_b', 'circuit')
         .select_related('connected_as_a', 'connected_as_b', 'circuit')
+    device_bays = DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer')
 
 
     # Gather any secrets which belong to this device
     # Gather any secrets which belong to this device
     secrets = device.secrets.all()
     secrets = device.secrets.all()
@@ -540,6 +551,7 @@ def device(request, pk):
         'power_outlets': power_outlets,
         'power_outlets': power_outlets,
         'interfaces': interfaces,
         'interfaces': interfaces,
         'mgmt_interfaces': mgmt_interfaces,
         'mgmt_interfaces': mgmt_interfaces,
+        'device_bays': device_bays,
         'ip_addresses': ip_addresses,
         'ip_addresses': ip_addresses,
         'secrets': secrets,
         'secrets': secrets,
         'related_devices': related_devices,
         'related_devices': related_devices,
@@ -550,7 +562,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_device'
     permission_required = 'dcim.change_device'
     model = Device
     model = Device
     form_class = forms.DeviceForm
     form_class = forms.DeviceForm
-    fields_initial = ['site', 'rack', 'position', 'face']
+    fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     template_name = 'dcim/device_edit.html'
     template_name = 'dcim/device_edit.html'
     cancel_url = 'dcim:device_list'
     cancel_url = 'dcim:device_list'
 
 
@@ -1343,6 +1355,143 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
 
 
 
 
 #
 #
+# Device bays
+#
+
+@permission_required('dcim.add_devicebay')
+def devicebay_add(request, pk):
+
+    device = get_object_or_404(Device, pk=pk)
+
+    if request.method == 'POST':
+        form = forms.DeviceBayCreateForm(request.POST)
+        if form.is_valid():
+
+            device_bays = []
+            for name in form.cleaned_data['name_pattern']:
+                devicebay_form = forms.DeviceBayForm({
+                    'device': device.pk,
+                    'name': name,
+                })
+                if devicebay_form.is_valid():
+                    device_bays.append(devicebay_form.save(commit=False))
+                else:
+                    for err in devicebay_form.errors.get('__all__', []):
+                        form.add_error('name_pattern', err)
+
+            if not form.errors:
+                DeviceBay.objects.bulk_create(device_bays)
+                messages.success(request, "Added {} device bay(s) to {}".format(len(device_bays), device))
+                if '_addanother' in request.POST:
+                    return redirect('dcim:devicebay_add', pk=device.pk)
+                else:
+                    return redirect('dcim:device', pk=device.pk)
+
+    else:
+        form = forms.DeviceBayCreateForm()
+
+    return render(request, 'dcim/devicebay_edit.html', {
+        'device': device,
+        'form': form,
+        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
+    })
+
+
+@permission_required('dcim.change_devicebay')
+def devicebay_edit(request, pk):
+
+    devicebay = get_object_or_404(DeviceBay, pk=pk)
+
+    if request.method == 'POST':
+        form = forms.DeviceBayForm(request.POST, instance=devicebay)
+        if form.is_valid():
+            devicebay = form.save()
+            messages.success(request, "Modified {} bay {}".format(devicebay.device.name, devicebay.name))
+            return redirect('dcim:device', pk=devicebay.device.pk)
+
+    else:
+        form = forms.DeviceBayForm(instance=devicebay)
+
+    return render(request, 'dcim/devicebay_edit.html', {
+        'devicebay': devicebay,
+        'form': form,
+        'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
+    })
+
+
+@permission_required('dcim.delete_devicebay')
+def devicebay_delete(request, pk):
+
+    devicebay = get_object_or_404(DeviceBay, pk=pk)
+
+    if request.method == 'POST':
+        form = ConfirmationForm(request.POST)
+        if form.is_valid():
+            devicebay.delete()
+            messages.success(request, "Device bay {} has been deleted from {}".format(devicebay, devicebay.device))
+            return redirect('dcim:device', pk=devicebay.device.pk)
+
+    else:
+        form = ConfirmationForm()
+
+    return render(request, 'dcim/devicebay_delete.html', {
+        'devicebay': devicebay,
+        'form': form,
+        'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
+    })
+
+
+@permission_required('dcim.change_devicebay')
+def devicebay_populate(request, pk):
+
+    device_bay = get_object_or_404(DeviceBay, pk=pk)
+
+    if request.method == 'POST':
+        form = forms.PopulateDeviceBayForm(device_bay, request.POST)
+        if form.is_valid():
+
+            device_bay.installed_device = form.cleaned_data['installed_device']
+            device_bay.save()
+
+            if not form.errors:
+                messages.success(request, "Added {} to {}".format(device_bay.installed_device, device_bay))
+                return redirect('dcim:device', pk=device_bay.device.pk)
+
+    else:
+        form = forms.PopulateDeviceBayForm(device_bay)
+
+    return render(request, 'dcim/devicebay_populate.html', {
+        'device_bay': device_bay,
+        'form': form,
+        'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
+    })
+
+
+@permission_required('dcim.change_devicebay')
+def devicebay_depopulate(request, pk):
+
+    device_bay = get_object_or_404(DeviceBay, pk=pk)
+
+    if request.method == 'POST':
+        form = ConfirmationForm(request.POST)
+        if form.is_valid():
+            removed_device = device_bay.installed_device
+            device_bay.installed_device = None
+            device_bay.save()
+            messages.success(request, "{} has been removed from {}".format(removed_device, device_bay))
+            return redirect('dcim:device', pk=device_bay.device.pk)
+
+    else:
+        form = ConfirmationForm()
+
+    return render(request, 'dcim/devicebay_depopulate.html', {
+        'device_bay': device_bay,
+        'form': form,
+        'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
+    })
+
+
+#
 # Interface connections
 # Interface connections
 #
 #
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -11,7 +11,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.0.8-dev'
+VERSION = '1.1.0-dev'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:

+ 6 - 1
netbox/templates/dcim/_rack_elevation.html

@@ -31,7 +31,12 @@
                 <li class="occupied h{{ u.device.device_type.u_height }}u{% ifequal u.device.face face_id %} {{ u.device.device_role.color }}{% endifequal %}">
                 <li class="occupied h{{ u.device.device_type.u_height }}u{% ifequal u.device.face face_id %} {{ u.device.device_role.color }}{% endifequal %}">
                     {% ifequal u.device.face face_id %}
                     {% ifequal u.device.face face_id %}
                         <a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
                         <a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
-                           data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">{{ u.device.name|default:u.device.device_role }}</a>
+                           data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">
+                            {{ u.device.name|default:u.device.device_role }}
+                            {% if u.device.devicebay_count %}
+                                ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
+                            {% endif %}
+                        </a>
                     {% else %}
                     {% else %}
                         <span>{{ u.device.name|default:u.device.device_role }}</span>
                         <span>{{ u.device.name|default:u.device.device_role }}</span>
                     {% endifequal %}
                     {% endifequal %}

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

@@ -29,7 +29,12 @@
                 <tr>
                 <tr>
                     <td>Position</td>
                     <td>Position</td>
                     <td>
                     <td>
-                        {% if device.position %}
+                        {% if device.parent_bay %}
+                            {% with device.parent_bay.device as parent %}
+                                <span>U{{ parent.position }} / {{ parent.get_face_display }}
+                                (<a href="{{ parent.get_absolute_url }}">{{ parent }}</a> - {{ device.parent_bay.name }})</span>
+                            {% endwith %}
+                        {% elif 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.device_type.u_height %}
                             <span class="label label-warning">Not racked</span>
                             <span class="label label-warning">Not racked</span>
@@ -268,6 +273,25 @@
         </div>
         </div>
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
+        {% if device_bays or device.device_type.is_parent_device %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    {% if perms.dcim.add_devicebay %}
+                        <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Device Bays</a>
+                    {% endif %}
+                    <strong>Device Bays</strong>
+                </div>
+                <table class="table table-hover panel-body">
+                    {% for devicebay in device_bays %}
+                        {% include 'dcim/inc/_devicebay.html' %}
+                    {% empty %}
+                        <tr>
+                            <td colspan="4">No device bays defined</td>
+                        </tr>
+                    {% endfor %}
+                </table>
+            </div>
+        {% endif %}
         {% if interfaces or device.device_type.is_network_device %}
         {% if interfaces or device.device_type.is_network_device %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">

+ 8 - 0
netbox/templates/dcim/devicebay_delete.html

@@ -0,0 +1,8 @@
+{% extends 'utilities/confirmation_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Delete device bay {{ devicebay }}?{% endblock %}
+
+{% block message %}
+    <p>Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?</p>
+{% endblock %}

+ 8 - 0
netbox/templates/dcim/devicebay_depopulate.html

@@ -0,0 +1,8 @@
+{% extends 'utilities/confirmation_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblock %}
+
+{% block message %}
+    <p>Are you sure you want to remove <strong>{{ device_bay.installed_device }}</strong> from <strong>{{ device_bay }}</strong>?</p>
+{% endblock %}

+ 51 - 0
netbox/templates/dcim/devicebay_edit.html

@@ -0,0 +1,51 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block title %}{% if devicebay.pk %}Editing {{ devicebay.device }} {{ devicebay }}{% else %}Add a Device Bay ({{ device }}){% endif %}{% endblock %}
+
+{% block content %}
+<form action="." method="post" class="form form-horizontal">
+    {% csrf_token %}
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            {% if form.non_field_errors %}
+                <div class="panel panel-danger">
+                    <div class="panel-heading"><strong>Errors</strong></div>
+                    <div class="panel-body">
+                        {{ form.non_field_errors }}
+                    </div>
+                </div>
+            {% endif %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    {% if poweroutlet.pk %}
+                        <strong>Editing {{ devicebay }}</strong>
+                    {% else %}
+                        <strong>Add a Device Bay</strong>
+                    {% endif %}
+                </div>
+                <div class="panel-body">
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Device</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{% if devicebay %}{{ devicebay.device }}{% else %}{{ device }}{% endif %}</p>
+                        </div>
+                    </div>
+                    {% render_form form %}
+                </div>
+            </div>
+		    <div class="form-group">
+                <div class="col-md-9 col-md-offset-3">
+                    {% if devicebay.pk %}
+                        <button type="submit" name="_update" class="btn btn-primary">Save</button>
+                    {% else %}
+                        <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                        <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
+                    {% endif %}
+                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                </div>
+		    </div>
+        </div>
+    </div>
+</form>
+{% endblock %}

+ 46 - 0
netbox/templates/dcim/devicebay_populate.html

@@ -0,0 +1,46 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block title %}Populate {{ device_bay }}{% endblock %}
+
+{% block content %}
+<form action="." method="post" class="form form-horizontal">
+    {% csrf_token %}
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            {% if form.non_field_errors %}
+                <div class="panel panel-danger">
+                    <div class="panel-heading"><strong>Errors</strong></div>
+                    <div class="panel-body">
+                        {{ form.non_field_errors }}
+                    </div>
+                </div>
+            {% endif %}
+            <div class="panel panel-default">
+                <div class="panel-heading">Populate {{ device_bay }}</div>
+                <div class="panel-body">
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Parent Device</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ device_bay.device }}</p>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Bay</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ device_bay.name }}</p>
+                        </div>
+                    </div>
+                    {% render_form form %}
+                </div>
+            </div>
+		    <div class="form-group">
+                <div class="col-md-9 col-md-offset-3">
+                    <button type="submit" name="_update" class="btn btn-primary">Save</button>
+                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                </div>
+		    </div>
+        </div>
+    </div>
+</form>
+{% endblock %}

+ 1 - 0
netbox/templates/dcim/devicetype.html

@@ -77,6 +77,7 @@
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
     </div>
     </div>
 	<div class="col-md-6">
 	<div class="col-md-6">
+        {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}

+ 4 - 0
netbox/templates/dcim/inc/_device_header.html

@@ -5,6 +5,10 @@
             <li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
             <li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
             <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 %}
+                <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
+                <li>{{ device.parent_bay.name }}</li>
+            {% endif %}
             <li>{{ device }}</li>
             <li>{{ device }}</li>
         </ol>
         </ol>
     {% endif %}
     {% endif %}

+ 44 - 0
netbox/templates/dcim/inc/_devicebay.html

@@ -0,0 +1,44 @@
+<tr>
+    <td>
+        <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
+    </td>
+    {% if devicebay.installed_device %}
+        <td>
+            <a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
+        </td>
+        <td>
+            <span>{{ devicebay.installed_device.device_type }}</span>
+        </td>
+    {% else %}
+        <td colspan="2">
+            <span class="text-muted">Vacant</span>
+        </td>
+    {% endif %}
+    <td class="text-right">
+        {% if perms.dcim.change_devicebay %}
+            {% if devicebay.installed_device %}
+                <a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
+                    <i class="glyphicon glyphicon-remove" aria-hidden="true" title="Remove device"></i>
+                </a>
+            {% else %}
+                <a href="{% url 'dcim:devicebay_populate' pk=devicebay.pk %}" class="btn btn-success btn-xs">
+                    <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
+                </a>
+            {% endif %}
+            <a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_devicebay %}
+            {% if devicebay.installed_device %}
+                <button class="btn btn-danger btn-xs" disabled="disabled">
+                    <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+                </button>
+            {% else %}
+                <a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
+                    <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
+                </a>
+            {% endif %}
+        {% endif %}
+    </td>
+</tr>

+ 7 - 0
netbox/templates/dcim/rack.html

@@ -112,6 +112,12 @@
             </div>
             </div>
             {% if nonracked_devices %}
             {% if nonracked_devices %}
                 <table class="table table-hover panel-body">
                 <table class="table table-hover panel-body">
+                    <tr>
+                        <th>Name</th>
+                        <th>Role</th>
+                        <th>Type</th>
+                        <th>Parent</th>
+                    </tr>
                     {% for device in nonracked_devices %}
                     {% for device in nonracked_devices %}
                         <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
                         <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
                             <td>
                             <td>
@@ -119,6 +125,7 @@
                             </td>
                             </td>
                             <td>{{ device.device_role }}</td>
                             <td>{{ device.device_role }}</td>
                             <td>{{ device.device_type }}</td>
                             <td>{{ device.device_type }}</td>
+                            <td>{% if device.parent_bay %}<a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>{% endif %}</td>
                         </tr>
                         </tr>
                     {% endfor %}
                     {% endfor %}
                 </table>
                 </table>