Browse Source

Initial work on #91: Support for subdevices

Jeremy Stretch 8 years ago
parent
commit
067f22e444

+ 19 - 4
netbox/dcim/admin.py

@@ -2,9 +2,9 @@ from django.contrib import admin
 from django.db.models import Count
 
 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
 
 
+class DeviceBayTemplateAdmin(admin.TabularInline):
+    model = DeviceBayTemplate
+
+
 @admin.register(DeviceType)
 class DeviceTypeAdmin(admin.ModelAdmin):
     prepopulated_fields = {
@@ -72,9 +76,10 @@ class DeviceTypeAdmin(admin.ModelAdmin):
         PowerPortTemplateAdmin,
         PowerOutletTemplateAdmin,
         InterfaceTemplateAdmin,
+        DeviceBayTemplateAdmin,
     ]
     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']
 
     def get_queryset(self, request):
@@ -84,6 +89,7 @@ class DeviceTypeAdmin(admin.ModelAdmin):
             power_port_count=Count('power_port_templates', distinct=True),
             power_outlet_count=Count('power_outlet_templates', distinct=True),
             interface_count=Count('interface_templates', distinct=True),
+            devicebay_count=Count('devicebay_templates', distinct=True),
         )
 
     def console_ports(self, instance):
@@ -101,6 +107,9 @@ class DeviceTypeAdmin(admin.ModelAdmin):
     def interfaces(self, instance):
         return instance.interface_count
 
+    def device_bays(self, instance):
+        return instance.devicebay_count
+
 
 #
 # Devices
@@ -144,6 +153,11 @@ class InterfaceAdmin(admin.TabularInline):
     model = Interface
 
 
+class DeviceBayAdmin(admin.TabularInline):
+    model = DeviceBay
+    fk_name = 'device'
+
+
 class ModuleAdmin(admin.TabularInline):
     model = Module
     readonly_fields = ['parent', 'discovered']
@@ -157,6 +171,7 @@ class DeviceAdmin(admin.ModelAdmin):
         PowerPortAdmin,
         PowerOutletAdmin,
         InterfaceAdmin,
+        DeviceBayAdmin,
         ModuleAdmin,
     ]
     list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']

+ 48 - 5
netbox/dcim/forms.py

@@ -10,10 +10,10 @@ from utilities.forms 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:
         model = DeviceType
         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):
@@ -283,6 +283,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
         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
 #
@@ -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
 #
 

+ 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.validators import MinValueValidator
 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 utilities.fields import NullableCharField
@@ -18,6 +18,14 @@ RACK_FACE_CHOICES = [
     [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_GREEN = 'green'
 COLOR_BLUE = 'blue'
@@ -274,6 +282,7 @@ class Rack(CreatedUpdatedModel):
         # Add devices to rack units list
         if self.pk:
             for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
+                    .annotate(devicebay_count=Count('device_bays'))\
                     .exclude(pk=exclude)\
                     .filter(rack=self, position__gt=0)\
                     .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")
     is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
                                             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:
         ordering = ['manufacturer', 'model']
@@ -389,11 +402,24 @@ class DeviceType(models.Model):
         ]
 
     def __unicode__(self):
-        return "{0} {1}".format(self.manufacturer, self.model)
+        return "{} {}".format(self.manufacturer, self.model)
 
     def get_absolute_url(self):
         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):
     """
@@ -481,6 +507,21 @@ class InterfaceTemplate(models.Model):
         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
 #
@@ -563,6 +604,10 @@ class Device(CreatedUpdatedModel):
 
     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
         if self.position and self.face is None:
             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,
                            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):
         return ','.join([
@@ -643,6 +692,12 @@ class Device(CreatedUpdatedModel):
             return self.name
         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):
         """
         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):
     """
     A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only

+ 15 - 2
netbox/dcim/tables.py

@@ -4,8 +4,8 @@ from django_tables2.utils import Accessor
 from utilities.tables import BaseTable, ToggleColumn
 
 from .models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, InterfaceTemplate,
-    Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
+    DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
+    InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
 )
 
 
@@ -201,6 +201,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
 #

+ 13 - 1
netbox/dcim/urls.py

@@ -4,7 +4,8 @@ from secrets.views import secret_add
 
 from . import views
 from .models import (
-    ConsolePortTemplate, ConsoleServerPortTemplate, PowerPortTemplate, PowerOutletTemplate, InterfaceTemplate,
+    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
+    InterfaceTemplate,
 )
 
 
@@ -70,6 +71,10 @@ urlpatterns = [
         name='devicetype_add_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.component_template_delete,
         {'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
     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+)/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
     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'),

+ 153 - 4
netbox/dcim/views.py

@@ -24,8 +24,9 @@ from utilities.views import (
 from . import filters, forms, tables
 from .models import (
     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)
 
-    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()
     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))
     poweroutlet_table = tables.PowerOutletTemplateTable(PowerOutletTemplate.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'):
         consoleport_table.base_columns['pk'].visible = True
         consoleserverport_table.base_columns['pk'].visible = True
         powerport_table.base_columns['pk'].visible = True
         poweroutlet_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', {
         'devicetype': devicetype,
@@ -277,6 +281,7 @@ def devicetype(request, pk):
         'powerport_table': powerport_table,
         'poweroutlet_table': poweroutlet_table,
         'interface_table': interface_table,
+        'devicebay_table': devicebay_table,
     })
 
 
@@ -395,6 +400,11 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
     form = forms.InterfaceTemplateForm
 
 
+class DeviceBayTemplateAddView(ComponentTemplateCreateView):
+    model = DeviceBayTemplate
+    form = forms.DeviceBayTemplateForm
+
+
 def component_template_delete(request, pk, model):
 
     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')
     mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
         .select_related('connected_as_a', 'connected_as_b', 'circuit')
+    device_bays = DeviceBay.objects.filter(device=device).select_related('installed_device')
 
     # Gather any secrets which belong to this device
     secrets = device.secrets.all()
@@ -540,6 +551,7 @@ def device(request, pk):
         'power_outlets': power_outlets,
         'interfaces': interfaces,
         'mgmt_interfaces': mgmt_interfaces,
+        'device_bays': device_bays,
         'ip_addresses': ip_addresses,
         'secrets': secrets,
         'related_devices': related_devices,
@@ -550,7 +562,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_device'
     model = Device
     form_class = forms.DeviceForm
-    fields_initial = ['site', 'rack', 'position', 'face']
+    fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     template_name = 'dcim/device_edit.html'
     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
 #
 

+ 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 %}">
                     {% 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"
-                           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 %}
                         <span>{{ u.device.name|default:u.device.device_role }}</span>
                     {% endifequal %}

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

@@ -29,7 +29,12 @@
                 <tr>
                     <td>Position</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>
                         {% elif device.device_type.u_height %}
                             <span class="label label-warning">Not racked</span>
@@ -268,6 +273,25 @@
         </div>
 	</div>
 	<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 %}
             <div class="panel panel-default">
                 <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' %}
     </div>
 	<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=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' %}

+ 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:rack_list' %}?site={{ device.rack.site.slug }}">Racks</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>
         </ol>
     {% endif %}

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

@@ -0,0 +1,39 @@
+<tr>
+    <td>
+        <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
+    </td>
+    <td>
+        {% if devicebay.installed_device %}
+            <a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
+        {% else %}
+            <span class="text-muted">Vacant</span>
+        {% endif %}
+    </td>
+    <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>
             {% if nonracked_devices %}
                 <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 %}
                         <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
                             <td>
@@ -119,6 +125,7 @@
                             </td>
                             <td>{{ device.device_role }}</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>
                     {% endfor %}
                 </table>