Просмотр исходного кода

Merge pull request #453 from digitalocean/develop

Release v1.5.0
Jeremy Stretch 9 лет назад
Родитель
Сommit
2509405465
46 измененных файлов с 678 добавлено и 138 удалено
  1. 1 1
      README.md
  2. 9 3
      docs/data-model/dcim.md
  3. BIN
      docs/netbox_logo.png
  4. 2 2
      netbox/circuits/admin.py
  5. 1 1
      netbox/circuits/api/serializers.py
  6. 3 3
      netbox/circuits/forms.py
  7. 20 0
      netbox/circuits/migrations/0005_circuit_add_upstream_speed.py
  8. 11 2
      netbox/circuits/models.py
  9. 10 2
      netbox/dcim/admin.py
  10. 43 5
      netbox/dcim/api/serializers.py
  11. 5 0
      netbox/dcim/api/urls.py
  12. 36 11
      netbox/dcim/api/views.py
  13. 12 1
      netbox/dcim/filters.py
  14. 73 8
      netbox/dcim/forms.py
  15. 25 0
      netbox/dcim/migrations/0014_rack_add_type_width.py
  16. 21 0
      netbox/dcim/migrations/0015_rack_add_u_height_validator.py
  17. 21 0
      netbox/dcim/migrations/0016_module_add_manufacturer.py
  18. 33 0
      netbox/dcim/migrations/0017_rack_add_role.py
  19. 60 4
      netbox/dcim/models.py
  20. 29 4
      netbox/dcim/tables.py
  21. 9 0
      netbox/dcim/tests/test_apis.py
  22. 28 20
      netbox/dcim/tests/test_forms.py
  23. 6 0
      netbox/dcim/urls.py
  24. 42 12
      netbox/dcim/views.py
  25. 2 6
      netbox/ipam/forms.py
  26. 7 6
      netbox/ipam/models.py
  27. 13 3
      netbox/ipam/tables.py
  28. 5 4
      netbox/ipam/views.py
  29. 1 2
      netbox/netbox/settings.py
  30. 36 21
      netbox/project-static/css/base.css
  31. BIN
      netbox/project-static/img/netbox.ico
  32. BIN
      netbox/project-static/img/netbox_logo.png
  33. 9 1
      netbox/templates/_base.html
  34. 5 4
      netbox/templates/circuits/circuit.html
  35. 1 0
      netbox/templates/circuits/circuit_edit.html
  36. 8 3
      netbox/templates/circuits/circuit_import.html
  37. 6 1
      netbox/templates/dcim/device_import_child.html
  38. 5 0
      netbox/templates/dcim/device_inventory.html
  39. 14 0
      netbox/templates/dcim/rack.html
  40. 13 2
      netbox/templates/dcim/rack_bulk_edit.html
  41. 2 0
      netbox/templates/dcim/rack_edit.html
  42. 11 1
      netbox/templates/dcim/rack_import.html
  43. 21 0
      netbox/templates/dcim/rackrole_list.html
  44. 7 0
      netbox/utilities/forms.py
  45. 7 0
      netbox/utilities/tables.py
  46. 5 5
      requirements.txt

+ 1 - 1
README.md

@@ -1,4 +1,4 @@
-# NetBox
+![NetBox](docs/netbox_logo.png "NetBox logo")
 
 NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
 

+ 9 - 3
docs/data-model/dcim.md

@@ -10,15 +10,21 @@ Sites can be assigned an optional facility ID to identify the actual facility ho
 
 # Racks
 
-Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units *(U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
+Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units* (U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
 
 Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number.
 
+The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches.
+
 ### Rack Groups
 
 Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room.
 
-Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not currently supported.
+Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported.
+
+### Rack Roles
+
+Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices.
 
 ---
 
@@ -74,7 +80,7 @@ The assignment of platforms to devices is an entirely optional feature, and may
 
 ### Modules
 
-A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand.
+A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer.
 
 ### Components
 

BIN
docs/netbox_logo.png


+ 2 - 2
netbox/circuits/admin.py

@@ -21,8 +21,8 @@ class CircuitTypeAdmin(admin.ModelAdmin):
 
 @admin.register(Circuit)
 class CircuitAdmin(admin.ModelAdmin):
-    list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
-                    'xconnect_id']
+    list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
+                    'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
     list_filter = ['provider', 'type', 'tenant']
     exclude = ['interface']
 

+ 1 - 1
netbox/circuits/api/serializers.py

@@ -53,7 +53,7 @@ class CircuitSerializer(serializers.ModelSerializer):
     class Meta:
         model = Circuit
         fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
-                  'commit_rate', 'xconnect_id', 'comments']
+                  'upstream_speed', 'commit_rate', 'xconnect_id', 'comments']
 
 
 class CircuitNestedSerializer(CircuitSerializer):

+ 3 - 3
netbox/circuits/forms.py

@@ -102,7 +102,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
         model = Circuit
         fields = [
             'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
-            'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
+            'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
         ]
         help_texts = {
             'cid': "Unique circuit ID",
@@ -169,8 +169,8 @@ class CircuitFromCSVForm(forms.ModelForm):
 
     class Meta:
         model = Circuit
-        fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
-                  'xconnect_id', 'pp_info']
+        fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
+                  'commit_rate', 'xconnect_id', 'pp_info']
 
 
 class CircuitImportForm(BulkImportForm, BootstrapMixin):

+ 20 - 0
netbox/circuits/migrations/0005_circuit_add_upstream_speed.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-08-08 20:24
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0004_circuit_add_tenant'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='upstream_speed',
+            field=models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', null=True, verbose_name=b'Upstream speed (Kbps)'),
+        ),
+    ]

+ 11 - 2
netbox/circuits/models.py

@@ -72,6 +72,8 @@ class Circuit(CreatedUpdatedModel):
     interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
     port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
+    upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
+                                                 help_text='Upstream speed, if different from port speed')
     commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
     xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
     pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
@@ -96,6 +98,7 @@ class Circuit(CreatedUpdatedModel):
             self.site.name,
             self.install_date.isoformat() if self.install_date else '',
             str(self.port_speed),
+            str(self.upstream_speed),
             str(self.commit_rate) if self.commit_rate else '',
             self.xconnect_id,
             self.pp_info,
@@ -116,12 +119,18 @@ class Circuit(CreatedUpdatedModel):
         else:
             return '{} Kbps'.format(speed)
 
-    @property
     def port_speed_human(self):
         return self._humanize_speed(self.port_speed)
+    port_speed_human.admin_order_field = 'port_speed'
+
+    def upstream_speed_human(self):
+        if not self.upstream_speed:
+            return ''
+        return self._humanize_speed(self.upstream_speed)
+    upstream_speed_human.admin_order_field = 'upstream_speed'
 
-    @property
     def commit_rate_human(self):
         if not self.commit_rate:
             return ''
         return self._humanize_speed(self.commit_rate)
+    commit_rate_human.admin_order_field = 'commit_rate'

+ 10 - 2
netbox/dcim/admin.py

@@ -4,7 +4,7 @@ from django.db.models import Count
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
-    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
+    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
 )
 
 
@@ -24,9 +24,17 @@ class RackGroupAdmin(admin.ModelAdmin):
     }
 
 
+@admin.register(RackRole)
+class RackRoleAdmin(admin.ModelAdmin):
+    list_display = ['name', 'slug', 'color']
+    prepopulated_fields = {
+        'slug': ['name'],
+    }
+
+
 @admin.register(Rack)
 class RackAdmin(admin.ModelAdmin):
-    list_display = ['name', 'facility_id', 'site', 'u_height']
+    list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
 
 
 #

+ 43 - 5
netbox/dcim/api/serializers.py

@@ -3,8 +3,8 @@ from rest_framework import serializers
 from ipam.models import IPAddress
 from dcim.models import (
     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,
+    DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
+    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
 )
 from tenancy.api.serializers import TenantNestedSerializer
 
@@ -46,6 +46,23 @@ class RackGroupNestedSerializer(RackGroupSerializer):
         fields = ['id', 'name', 'slug']
 
 
+#
+# Rack roles
+#
+
+class RackRoleSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = RackRole
+        fields = ['id', 'name', 'slug', 'color']
+
+
+class RackRoleNestedSerializer(RackRoleSerializer):
+
+    class Meta(RackRoleSerializer.Meta):
+        fields = ['id', 'name', 'slug']
+
+
 #
 # Racks
 #
@@ -55,10 +72,12 @@ class RackSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     group = RackGroupNestedSerializer()
     tenant = TenantNestedSerializer()
+    role = RackRoleNestedSerializer()
 
     class Meta:
         model = Rack
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments']
+        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
+                  'u_height', 'comments']
 
 
 class RackNestedSerializer(RackSerializer):
@@ -72,8 +91,8 @@ class RackDetailSerializer(RackSerializer):
     rear_units = serializers.SerializerMethodField()
 
     class Meta(RackSerializer.Meta):
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments',
-                  'front_units', 'rear_units']
+        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
+                  'u_height', 'comments', 'front_units', 'rear_units']
 
     def get_front_units(self, obj):
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -384,6 +403,25 @@ class DeviceBayDetailSerializer(DeviceBaySerializer):
         fields = ['id', 'device', 'name', 'installed_device']
 
 
+#
+# Modules
+#
+
+class ModuleSerializer(serializers.ModelSerializer):
+    device = DeviceNestedSerializer()
+    manufacturer = ManufacturerNestedSerializer()
+
+    class Meta:
+        model = Module
+        fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
+
+
+class ModuleNestedSerializer(ModuleSerializer):
+
+    class Meta(ModuleSerializer.Meta):
+        fields = ['id', 'device', 'parent', 'name']
+
+
 #
 # Interface connections
 #

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

@@ -18,6 +18,10 @@ urlpatterns = [
     url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
     url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
 
+    # Rack roles
+    url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'),
+    url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'),
+
     # Racks
     url(r'^racks/$', RackListView.as_view(), name='rack_list'),
     url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
@@ -50,6 +54,7 @@ urlpatterns = [
     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+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
+    url(r'^devices/(?P<pk>\d+)/modules/$', ModuleListView.as_view(), name='device_modules'),
 
     # Console ports
     url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),

+ 36 - 11
netbox/dcim/api/views.py

@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404
 
 from dcim.models import (
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
-    InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
+    InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
 )
 from dcim import filters
 from .exceptions import MissingFilterException
@@ -60,6 +60,26 @@ class RackGroupDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.RackGroupSerializer
 
 
+#
+# Rack roles
+#
+
+class RackRoleListView(generics.ListAPIView):
+    """
+    List all rack roles
+    """
+    queryset = RackRole.objects.all()
+    serializer_class = serializers.RackRoleSerializer
+
+
+class RackRoleDetailView(generics.RetrieveAPIView):
+    """
+    Retrieve a single rack role
+    """
+    queryset = RackRole.objects.all()
+    serializer_class = serializers.RackRoleSerializer
+
+
 #
 # Racks
 #
@@ -349,18 +369,23 @@ class DeviceBayListView(generics.ListAPIView):
     def get_queryset(self):
 
         device = get_object_or_404(Device, pk=self.kwargs['pk'])
-        queryset = DeviceBay.objects.filter(device=device).select_related('installed_device')
+        return 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
+#
+# Modules
+#
+
+class ModuleListView(generics.ListAPIView):
+    """
+    List device modules (by device)
+    """
+    serializer_class = serializers.ModuleSerializer
+
+    def get_queryset(self):
+
+        device = get_object_or_404(Device, pk=self.kwargs['pk'])
+        return Module.objects.filter(device=device).select_related('device', 'manufacturer')
 
 
 #

+ 12 - 1
netbox/dcim/filters.py

@@ -4,7 +4,7 @@ from django.db.models import Q
 
 from .models import (
     ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
-    Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
+    Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
 )
 from tenancy.models import Tenant
 
@@ -96,6 +96,17 @@ class RackFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Tenant (slug)',
     )
+    role_id = django_filters.ModelMultipleChoiceFilter(
+        name='role',
+        queryset=RackRole.objects.all(),
+        label='Role (ID)',
+    )
+    role = django_filters.ModelMultipleChoiceFilter(
+        name='role',
+        queryset=RackRole.objects.all(),
+        to_field_name='slug',
+        label='Role (slug)',
+    )
 
     class Meta:
         model = Rack

+ 73 - 8
netbox/dcim/forms.py

@@ -7,7 +7,7 @@ from ipam.models import IPAddress
 from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
+    APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
 )
 
@@ -15,7 +15,8 @@ from .models import (
     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
+    PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole,
+    Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
 )
 
 
@@ -49,6 +50,30 @@ def bulkedit_platform_choices():
     return choices
 
 
+def bulkedit_rackgroup_choices():
+    """
+    Include an option to remove the currently assigned group from a rack.
+    """
+    choices = [
+        (None, '---------'),
+        (0, 'None'),
+    ]
+    choices += [(r.pk, r) for r in RackGroup.objects.all()]
+    return choices
+
+
+def bulkedit_rackrole_choices():
+    """
+    Include an option to remove the currently assigned role from a rack.
+    """
+    choices = [
+        (None, '---------'),
+        (0, 'None'),
+    ]
+    choices += [(r.pk, r.name) for r in RackRole.objects.all()]
+    return choices
+
+
 #
 # Sites
 #
@@ -123,6 +148,18 @@ class RackGroupFilterForm(forms.Form, BootstrapMixin):
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
 
 
+#
+# Rack roles
+#
+
+class RackRoleForm(forms.ModelForm, BootstrapMixin):
+    slug = SlugField()
+
+    class Meta:
+        model = RackRole
+        fields = ['name', 'slug', 'color']
+
+
 #
 # Racks
 #
@@ -135,7 +172,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Rack
-        fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments']
+        fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments']
         help_texts = {
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
@@ -165,10 +202,13 @@ class RackFromCSVForm(forms.ModelForm):
     group_name = forms.CharField(required=False)
     tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
                                     error_messages={'invalid_choice': 'Tenant not found.'})
+    role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False,
+                                  error_messages={'invalid_choice': 'Role not found.'})
+    type = forms.CharField(required=False)
 
     class Meta:
         model = Rack
-        fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height']
+        fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height']
 
     def clean(self):
 
@@ -182,6 +222,19 @@ class RackFromCSVForm(forms.ModelForm):
             except RackGroup.DoesNotExist:
                 self.add_error('group_name', "Invalid rack group ({})".format(group))
 
+    def clean_type(self):
+        rack_type = self.cleaned_data['type']
+        if not rack_type:
+            return None
+        try:
+            choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES}
+            return choices[rack_type.lower()]
+        except KeyError:
+            raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format(
+                rack_type,
+                ', '.join({v: k for k, v in RACK_TYPE_CHOICES}),
+            ))
+
 
 class RackImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=RackFromCSVForm)
@@ -189,9 +242,12 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
 
 class RackBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
-    group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
+    site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
+    group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
     tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
+    type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
+    width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
     u_height = forms.IntegerField(required=False, label='Height (U)')
     comments = CommentField()
 
@@ -211,6 +267,11 @@ def rack_tenant_choices():
     return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
 
 
+def rack_role_choices():
+    role_choices = RackRole.objects.annotate(rack_count=Count('racks'))
+    return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices]
+
+
 class RackFilterForm(forms.Form, BootstrapMixin):
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
@@ -218,6 +279,8 @@ class RackFilterForm(forms.Form, BootstrapMixin):
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
     tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
                                        widget=forms.SelectMultiple(attrs={'size': 8}))
+    role = forms.MultipleChoiceField(required=False, choices=rack_role_choices,
+                                     widget=forms.SelectMultiple(attrs={'size': 8}))
 
 
 #
@@ -402,7 +465,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
         # Limit rack choices
-        if self.is_bound:
+        if self.is_bound and self.data.get('site'):
             self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
         elif self.initial.get('site'):
             self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
@@ -443,6 +506,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
         if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
             self.fields['site'].disabled = True
             self.fields['rack'].disabled = True
+            self.initial['site'] = self.instance.parent_bay.device.rack.site_id
+            self.initial['rack'] = self.instance.parent_bay.device.rack_id
 
 
 class BaseDeviceFromCSVForm(forms.ModelForm):
@@ -1254,4 +1319,4 @@ class ModuleForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Module
-        fields = ['name', 'part_id', 'serial']
+        fields = ['name', 'manufacturer', 'part_id', 'serial']

+ 25 - 0
netbox/dcim/migrations/0014_rack_add_type_width.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-08-08 21:11
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0013_add_interface_form_factors'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rack',
+            name='type',
+            field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='width',
+            field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'),
+        ),
+    ]

+ 21 - 0
netbox/dcim/migrations/0015_rack_add_u_height_validator.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-08-09 21:18
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0014_rack_add_type_width'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='rack',
+            name='u_height',
+            field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'),
+        ),
+    ]

+ 21 - 0
netbox/dcim/migrations/0016_module_add_manufacturer.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-08-10 13:45
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0015_rack_add_u_height_validator'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='module',
+            name='manufacturer',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'),
+        ),
+    ]

+ 33 - 0
netbox/dcim/migrations/0017_rack_add_role.py

@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-08-10 14:58
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0016_module_add_manufacturer'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RackRole',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+                ('color', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='role',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'),
+        ),
+    ]

+ 60 - 4
netbox/dcim/models.py

@@ -3,7 +3,7 @@ from collections import OrderedDict
 from django.conf import settings
 from django.core.exceptions import MultipleObjectsReturned, ValidationError
 from django.core.urlresolvers import reverse
-from django.core.validators import MinValueValidator
+from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 
@@ -16,6 +16,26 @@ from utilities.models import CreatedUpdatedModel
 from .fields import ASNField, MACAddressField
 
 
+RACK_TYPE_2POST = 100
+RACK_TYPE_4POST = 200
+RACK_TYPE_CABINET = 300
+RACK_TYPE_WALLFRAME = 1000
+RACK_TYPE_WALLCABINET = 1100
+RACK_TYPE_CHOICES = (
+    (RACK_TYPE_2POST, '2-post frame'),
+    (RACK_TYPE_4POST, '4-post frame'),
+    (RACK_TYPE_CABINET, '4-post cabinet'),
+    (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'),
+    (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'),
+)
+
+RACK_WIDTH_19IN = 19
+RACK_WIDTH_23IN = 23
+RACK_WIDTH_CHOICES = (
+    (RACK_WIDTH_19IN, '19 inches'),
+    (RACK_WIDTH_23IN, '23 inches'),
+)
+
 RACK_FACE_FRONT = 0
 RACK_FACE_REAR = 1
 RACK_FACE_CHOICES = [
@@ -41,7 +61,7 @@ COLOR_RED = 'red'
 COLOR_GRAY1 = 'light_gray'
 COLOR_GRAY2 = 'medium_gray'
 COLOR_GRAY3 = 'dark_gray'
-DEVICE_ROLE_COLOR_CHOICES = [
+ROLE_COLOR_CHOICES = [
     [COLOR_TEAL, 'Teal'],
     [COLOR_GREEN, 'Green'],
     [COLOR_BLUE, 'Blue'],
@@ -183,6 +203,10 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
     }).order_by(*ordering)
 
 
+#
+# Sites
+#
+
 class SiteManager(NaturalOrderByManager):
 
     def get_queryset(self):
@@ -244,6 +268,10 @@ class Site(CreatedUpdatedModel):
         return self.circuits.count()
 
 
+#
+# Racks
+#
+
 class RackGroup(models.Model):
     """
     Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@@ -268,6 +296,24 @@ class RackGroup(models.Model):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
 
 
+class RackRole(models.Model):
+    """
+    Racks can be organized by functional role, similar to Devices.
+    """
+    name = models.CharField(max_length=50, unique=True)
+    slug = models.SlugField(unique=True)
+    color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
+
+    class Meta:
+        ordering = ['name']
+
+    def __unicode__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
+
+
 class RackManager(NaturalOrderByManager):
 
     def get_queryset(self):
@@ -284,7 +330,12 @@ class Rack(CreatedUpdatedModel):
     site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
     group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
     tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
-    u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
+    role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
+    type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
+    width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
+                                             help_text='Rail-to-rail width')
+    u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
+                                                validators=[MinValueValidator(1), MaxValueValidator(100)])
     comments = models.TextField(blank=True)
 
     objects = RackManager()
@@ -320,6 +371,9 @@ class Rack(CreatedUpdatedModel):
             self.name,
             self.facility_id or '',
             self.tenant.name if self.tenant else '',
+            self.role.name if self.role else '',
+            self.get_type_display() if self.type else '',
+            self.width,
             str(self.u_height),
         ])
 
@@ -627,7 +681,7 @@ class DeviceRole(models.Model):
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
-    color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES)
+    color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
 
     class Meta:
         ordering = ['name']
@@ -1073,6 +1127,8 @@ class Module(models.Model):
     device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
     parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
     name = models.CharField(max_length=50, verbose_name='Name')
+    manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True,
+                                     on_delete=models.PROTECT)
     part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
     serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
     discovered = models.BooleanField(default=False, verbose_name='Discovered')

+ 29 - 4
netbox/dcim/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, ColorColumn, ToggleColumn
 
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
@@ -22,6 +22,12 @@ RACKGROUP_ACTIONS = """
 {% endif %}
 """
 
+RACKROLE_ACTIONS = """
+{% if perms.dcim.change_rackrole %}
+    <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
 DEVICEROLE_ACTIONS = """
 {% if perms.dcim.change_devicerole %}
     <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -94,6 +100,24 @@ class RackGroupTable(BaseTable):
         fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'actions')
 
 
+#
+# Rack roles
+#
+
+class RackRoleTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn(verbose_name='Name')
+    rack_count = tables.Column(verbose_name='Racks')
+    color = ColorColumn(verbose_name='Color')
+    slug = tables.Column(verbose_name='Slug')
+    actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
+
+    class Meta(BaseTable.Meta):
+        model = RackGroup
+        fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
+
+
 #
 # Racks
 #
@@ -105,6 +129,7 @@ class RackTable(BaseTable):
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
+    role = tables.Column(verbose_name='Role')
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
     u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
@@ -112,7 +137,7 @@ class RackTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'u_height', 'devices', 'u_consumed',
+        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed',
                   'utilization')
 
 
@@ -233,14 +258,14 @@ class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
+    color = ColorColumn(verbose_name='Color')
     slug = tables.Column(verbose_name='Slug')
-    color = tables.Column(verbose_name='Color')
     actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
                                     verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = DeviceRole
-        fields = ('pk', 'name', 'device_count', 'slug', 'color', 'actions')
+        fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions')
 
 
 #

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

@@ -42,6 +42,9 @@ class SiteTest(APITestCase):
         'site',
         'group',
         'tenant',
+        'role',
+        'type',
+        'width',
         'u_height',
         'comments'
     ]
@@ -118,6 +121,9 @@ class RackTest(APITestCase):
         'site',
         'group',
         'tenant',
+        'role',
+        'type',
+        'width',
         'u_height',
         'comments'
     ]
@@ -130,6 +136,9 @@ class RackTest(APITestCase):
         'site',
         'group',
         'tenant',
+        'role',
+        'type',
+        'width',
         'u_height',
         'comments',
         'front_units',

+ 28 - 20
netbox/dcim/tests/test_forms.py

@@ -13,59 +13,67 @@ class DeviceTestCase(TestCase):
 
     def test_racked_device(self):
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'leaf-switch'),
             'name': 'test',
+            'device_role': get_id(DeviceRole, 'leaf-switch'),
+            'tenant': None,
+            'manufacturer': get_id(Manufacturer, 'juniper'),
+            'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': RACK_FACE_FRONT,
-            'platform': get_id(Platform, 'juniper-junos'),
-            'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'position': 41,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'juniper'),
+            'platform': get_id(Platform, 'juniper-junos'),
+            'status': STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid(), test.fields['position'].choices)
         self.assertTrue(test.save())
 
     def test_racked_device_occupied(self):
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'leaf-switch'),
             'name': 'test',
+            'device_role': get_id(DeviceRole, 'leaf-switch'),
+            'tenant': None,
+            'manufacturer': get_id(Manufacturer, 'juniper'),
+            'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': RACK_FACE_FRONT,
-            'platform': get_id(Platform, 'juniper-junos'),
-            'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'position': 1,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'juniper'),
+            'platform': get_id(Platform, 'juniper-junos'),
+            'status': STATUS_ACTIVE,
         })
         self.assertFalse(test.is_valid())
 
     def test_non_racked_device(self):
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'pdu'),
             'name': 'test',
+            'device_role': get_id(DeviceRole, 'pdu'),
+            'tenant': None,
+            'manufacturer': get_id(Manufacturer, 'servertech'),
+            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': None,
-            'platform': None,
-            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'position': None,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'servertech'),
+            'platform': None,
+            'status': STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())
 
     def test_non_racked_device_with_face(self):
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'pdu'),
             'name': 'test',
+            'device_role': get_id(DeviceRole, 'pdu'),
+            'tenant': None,
+            'manufacturer': get_id(Manufacturer, 'servertech'),
+            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': RACK_FACE_REAR,
-            'platform': None,
-            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'position': None,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'servertech'),
+            'platform': None,
+            'status': STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())

+ 6 - 0
netbox/dcim/urls.py

@@ -26,6 +26,12 @@ urlpatterns = [
     url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
 
+    # Rack roles
+    url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
+    url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'),
+    url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
+    url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
+
     # Racks
     url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
     url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),

+ 42 - 12
netbox/dcim/views.py

@@ -8,6 +8,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.db.models import Count, Sum
+from django.db.models.functions import Coalesce
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.http import urlencode
@@ -26,7 +27,7 @@ from .models import (
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    Site,
+    RackRole, Site,
 )
 
 
@@ -137,7 +138,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 #
 
 class RackGroupListView(ObjectListView):
-    queryset = RackGroup.objects.annotate(rack_count=Count('racks'))
+    queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
     filter = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
@@ -149,6 +150,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_rackgroup'
     model = RackGroup
     form_class = forms.RackGroupForm
+    success_url = 'dcim:rackgroup_list'
     cancel_url = 'dcim:rackgroup_list'
 
 
@@ -158,13 +160,39 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_redirect_url = 'dcim:rackgroup_list'
 
 
+#
+# Rack roles
+#
+
+class RackRoleListView(ObjectListView):
+    queryset = RackRole.objects.annotate(rack_count=Count('racks'))
+    table = tables.RackRoleTable
+    edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
+    template_name = 'dcim/rackrole_list.html'
+
+
+class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_rackrole'
+    model = RackRole
+    form_class = forms.RackRoleForm
+    success_url = 'dcim:rackrole_list'
+    cancel_url = 'dcim:rackrole_list'
+
+
+class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_rackrole'
+    cls = RackRole
+    default_redirect_url = 'dcim:rackrole_list'
+
+
 #
 # Racks
 #
 
 class RackListView(ObjectListView):
-    queryset = Rack.objects.select_related('site').prefetch_related('devices__device_type')\
-        .annotate(device_count=Count('devices', distinct=True), u_consumed=Sum('devices__device_type__u_height'))
+    queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\
+        .annotate(device_count=Count('devices', distinct=True),
+                  u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0))
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     table = tables.RackTable
@@ -223,11 +251,12 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        if form.cleaned_data['tenant'] == 0:
-            fields_to_update['tenant'] = None
-        elif form.cleaned_data['tenant']:
-            fields_to_update['tenant'] = form.cleaned_data['tenant']
-        for field in ['site', 'group', 'tenant', 'u_height', 'comments']:
+        for field in ['group', 'tenant', 'role']:
+            if form.cleaned_data[field] == 0:
+                fields_to_update[field] = None
+            elif form.cleaned_data[field]:
+                fields_to_update[field] = form.cleaned_data[field]
+        for field in ['site', 'type', 'width', 'u_height', 'comments']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
 
@@ -533,8 +562,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class DeviceListView(ObjectListView):
-    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip4',
-                                             'primary_ip6')
+    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site',
+                                             'primary_ip4', 'primary_ip6')
     filter = filters.DeviceFilter
     filter_form = forms.DeviceFilterForm
     table = tables.DeviceTable
@@ -680,7 +709,8 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 def device_inventory(request, pk):
 
     device = get_object_or_404(Device, pk=pk)
-    modules = Module.objects.filter(device=device, parent=None).prefetch_related('submodules')
+    modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\
+        .prefetch_related('submodules')
 
     return render(request, 'dcim/device_inventory.html', {
         'device': device,

+ 2 - 6
netbox/ipam/forms.py

@@ -182,18 +182,14 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
             self.fields['vlan'].choices = []
 
     def clean_prefix(self):
-        data = self.cleaned_data['prefix']
-        try:
-            prefix = IPNetwork(data)
-        except:
-            raise
+        prefix = self.cleaned_data['prefix']
         if prefix.version == 4 and prefix.prefixlen == 32:
             raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
                                         "addresses instead.")
         elif prefix.version == 6 and prefix.prefixlen == 128:
             raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
                                         "addresses instead.")
-        return data
+        return prefix
 
 
 class PrefixFromCSVForm(forms.ModelForm):

+ 7 - 6
netbox/ipam/models.py

@@ -254,12 +254,13 @@ class Prefix(CreatedUpdatedModel):
 
     def clean(self):
         # Disallow host masks
-        if self.prefix.version == 4 and self.prefix.prefixlen == 32:
-            raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
-                                  "instead.")
-        elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
-            raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
-                                  "instead.")
+        if self.prefix:
+            if self.prefix.version == 4 and self.prefix.prefixlen == 32:
+                raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
+                                      "instead.")
+            elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
+                raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
+                                      "instead.")
 
     def save(self, *args, **kwargs):
         if self.prefix:

+ 13 - 3
netbox/ipam/tables.py

@@ -43,12 +43,22 @@ IPADDRESS_LINK = """
 {% if record.pk %}
     <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
 {% elif perms.ipam.add_ipaddress %}
-    <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
+    <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
 {% else %}
     {{ record.0 }}
 {% endif %}
 """
 
+VRF_LINK = """
+{% if record.vrf %}
+    <a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
+{% elif prefix.vrf %}
+    {{ prefix.vrf }}
+{% else %}
+    Global
+{% endif %}
+"""
+
 STATUS_LABEL = """
 {% if record.pk %}
     <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
@@ -149,7 +159,7 @@ class PrefixTable(BaseTable):
     pk = ToggleColumn()
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
-    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
+    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     role = tables.Column(verbose_name='Role')
@@ -183,7 +193,7 @@ class PrefixBriefTable(BaseTable):
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
-    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
+    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
                                verbose_name='Device')

+ 5 - 4
netbox/ipam/views.py

@@ -305,7 +305,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class PrefixListView(ObjectListView):
-    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role')
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixTable
@@ -445,7 +445,7 @@ def prefix_ipaddresses(request, pk):
 #
 
 class IPAddressListView(ObjectListView):
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'interface__device')
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable
@@ -550,7 +550,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class VLANGroupListView(ObjectListView):
-    queryset = VLANGroup.objects.annotate(vlan_count=Count('vlans'))
+    queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
     filter = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
@@ -562,6 +562,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'ipam.change_vlangroup'
     model = VLANGroup
     form_class = forms.VLANGroupForm
+    success_url = 'ipam:vlangroup_list'
     cancel_url = 'ipam:vlangroup_list'
 
 
@@ -576,7 +577,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class VLANListView(ObjectListView):
-    queryset = VLAN.objects.select_related('site', 'role')
+    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     table = tables.VLANTable

+ 1 - 2
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
 
 
-VERSION = '1.4.2'
+VERSION = '1.5.0'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -172,7 +172,6 @@ MESSAGE_TAGS = {
 # Authentication URLs
 LOGIN_URL = '/login/'
 LOGIN_REDIRECT_URL = '/'
-LOGOUT_URL = '/logout/'
 
 # Secrets
 SECRETS_MIN_PUBKEY_SIZE = 2048

+ 36 - 21
netbox/project-static/css/base.css

@@ -21,6 +21,9 @@ body {
     margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
     padding-bottom: 30px;
 }
+.navbar-brand {
+    padding: 12px 15px 8px;
+}
 .footer, .push {
     height: 60px; /* .push must be the same height as .footer */
 }
@@ -291,27 +294,39 @@ ul.rack_near_face li.empty:hover a {
     display: block;
 }
 
-/* Rack elevation colors (from http://flatuicolors.com) */
-.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; }
-.teal:hover { background-color: #16a085; }
-.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; }
-.green:hover { background-color: #27ae60; }
-.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; }
-.blue:hover { background-color: #2980b9; }
-.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; }
-.purple:hover { background-color: #8e44ad; }
-.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; }
-.yellow:hover { background-color: #f39c12; }
-.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; }
-.orange:hover { background-color: #d35400; }
-.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; }
-.red:hover { background-color: #c0392b; }
-.light_gray { background-color: #dce2e3; border-bottom: 1px solid #bdc3c7; }
-.light_gray:hover { background-color: #bdc3c7; }
-.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; }
-.medium_gray:hover { background-color: #7f8c8d; }
-.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; }
-.dark_gray:hover { background-color: #2c3e50; }
+/* Colors (from http://flatuicolors.com) */
+.teal { background-color: #1abc9c; }
+.green { background-color: #2ecc71; }
+.blue { background-color: #3498db; }
+.purple { background-color: #9b59b6; }
+.yellow { background-color: #f1c40f; }
+.orange { background-color: #e67e22; }
+.red { background-color: #e74c3c; }
+.light_gray { background-color: #dce2e3; }
+.medium_gray { background-color: #95a5a6; }
+.dark_gray { background-color: #34495e; }
+
+/* Rack elevation coloring */
+ul.rack .teal { border-bottom: 1px solid #16a085; }
+ul.rack .teal:hover { background-color: #16a085; }
+ul.rack .green { border-bottom: 1px solid #27ae60; }
+ul.rack .green:hover { background-color: #27ae60; }
+ul.rack .blue { border-bottom: 1px solid #2980b9; }
+ul.rack .blue:hover { background-color: #2980b9; }
+ul.rack .purple { border-bottom: 1px solid #8e44ad; }
+ul.rack .purple:hover { background-color: #8e44ad; }
+ul.rack .yellow { border-bottom: 1px solid #f39c12; }
+ul.rack .yellow:hover { background-color: #f39c12; }
+ul.rack .orange { border-bottom: 1px solid #d35400; }
+ul.rack .orange:hover { background-color: #d35400; }
+ul.rack .red { border-bottom: 1px solid #c0392b; }
+ul.rack .red:hover { background-color: #c0392b; }
+ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; }
+ul.rack .light_gray:hover { background-color: #bdc3c7; }
+ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; }
+ul.rack .medium_gray:hover { background-color: #7f8c8d; }
+ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; }
+ul.rack .dark_gray:hover { background-color: #2c3e50; }
 
 /* Misc */
 .banner-bottom {

BIN
netbox/project-static/img/netbox.ico


BIN
netbox/project-static/img/netbox_logo.png


+ 9 - 1
netbox/templates/_base.html

@@ -8,6 +8,7 @@
     <link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
 	<link rel="stylesheet" href="{% static 'css/base.css' %}">
+    <link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
 </head>
 <body>
     <nav class="navbar navbar-default navbar-fixed-top">
@@ -19,7 +20,9 @@
                     <span class="icon-bar"></span>
                     <span class="icon-bar"></span>
                 </button>
-                <a class="navbar-brand" href="/">NetBox</a>
+                <a class="navbar-brand" href="/">
+                    <img src="{% static 'img/netbox_logo.png' %}" />
+                </a>
             </div>
             <div id="navbar" class="navbar-collapse collapse">
                 {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
@@ -58,6 +61,11 @@
                             {% if perms.dcim.add_rackgroup %}
                                 <li><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
                             {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'dcim:rackrole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Roles</a></li>
+                            {% if perms.dcim.add_rackrole %}
+                                <li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
+                            {% endif %}
                         </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">

+ 5 - 4
netbox/templates/circuits/circuit.html

@@ -82,12 +82,13 @@
                     </td>
                 </tr>
                 <tr>
-                    <td>Port Speed</td>
+                    <td>Speed</td>
                     <td>
-                        {% if circuit.port_speed %}
-                            {{ circuit.port_speed_human }}
+                        {% if circuit.upstream_speed %}
+                            <i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }} &nbsp;
+                            <i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
                         {% else %}
-                            <span class="text-muted">N/A</span>
+                            {{ circuit.port_speed_human }}
                         {% endif %}
                     </td>
                 </tr>

+ 1 - 0
netbox/templates/circuits/circuit_edit.html

@@ -19,6 +19,7 @@
         <div class="panel-heading"><strong>Bandwidth</strong></div>
         <div class="panel-body">
             {% render_field form.port_speed %}
+            {% render_field form.upstream_speed %}
             {% render_field form.commit_rate %}
         </div>
     </div>

+ 8 - 3
netbox/templates/circuits/circuit_import.html

@@ -60,8 +60,13 @@
 				</tr>
 				<tr>
 					<td>Port Speed</td>
-					<td>Physical speed in Kbps/td>
-					<td>10000</td>
+					<td>Physical speed in Kbps</td>
+					<td>100000</td>
+				</tr>
+				<tr>
+					<td>Upstream Speed</td>
+					<td>Upstream speed in Kbps (optional)</td>
+					<td>20000</td>
 				</tr>
 				<tr>
 					<td>Commit rate</td>
@@ -81,7 +86,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
+		<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
 	</div>
 </div>
 {% endblock %}

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

@@ -36,6 +36,11 @@
 					<td>Functional role of device</td>
 					<td>Blade Server</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 					<td>Device manufacturer</td>
 					<td>Hardware manufacturer</td>
@@ -69,7 +74,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>Blade12,Blade Server,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4</pre>
+		<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4</pre>
 	</div>
 </div>
 {% endblock %}

+ 5 - 0
netbox/templates/dcim/device_inventory.html

@@ -32,6 +32,7 @@
                     <tr>
                         <th>Module</th>
                         <th></th>
+                        <th>Manufacturer</th>
                         <th>Part Number</th>
                         <th>Serial Number</th>
                         <th></th>
@@ -42,6 +43,7 @@
                         <tr>
                             <td>{{ m.name }}</td>
                             <td>{% if not m.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
+                            <td>{{ m.manufacturer|default:'' }}</td>
                             <td>{{ m.part_id }}</td>
                             <td>{{ m.serial }}</td>
                             <td class="text-right">
@@ -57,6 +59,7 @@
                             <tr>
                                 <td style="padding-left: 20px">{{ m2.name }}</td>
                                 <td>{% if not m2.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
+                                <td>{{ m2.manufacturer|default:'' }}</td>
                                 <td>{{ m2.part_id }}</td>
                                 <td>{{ m2.serial }}</td>
                                 <td class="text-right">
@@ -72,6 +75,7 @@
                                 <tr>
                                     <td style="padding-left: 40px">{{ m3.name }}</td>
                                     <td>{% if not m3.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
+                                    <td>{{ m3.manufacturer|default:'' }}</td>
                                     <td>{{ m3.part_id }}</td>
                                     <td>{{ m3.serial }}</td>
                                     <td class="text-right">
@@ -87,6 +91,7 @@
                                     <tr>
                                         <td style="padding-left: 60px">{{ m4.name }}</td>
                                         <td>{% if not m4.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
+                                        <td>{{ m4.manufacturer|default:'' }}</td>
                                         <td>{{ m4.part_id }}</td>
                                         <td>{{ m4.serial }}</td>
                                         <td class="text-right">

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

@@ -96,6 +96,20 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Type</td>
+                    <td>
+                        {% if rack.type %}
+                            {{ rack.get_type_display }}
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Width</td>
+                    <td>{{ rack.get_width_display }}</td>
+                </tr>
                 <tr>
                     <td>Height</td>
                     <td>{{ rack.u_height }}U</td>

+ 13 - 2
netbox/templates/dcim/rack_bulk_edit.html

@@ -4,13 +4,24 @@
 {% block title %}Rack Bulk Edit{% endblock %}
 
 {% block select_objects_table %}
+    <tr>
+        <th>Name</th>
+        <th>Site</th>
+        <th>Group</th>
+        <th>Tenant</th>
+        <th>Type</th>
+        <th>Width</th>
+        <th>Height</th>
+    </tr>
     {% for rack in selected_objects %}
         <tr>
             <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
-            <td>{{ rack.facility_id }}</td>
             <td>{{ rack.site }}</td>
+            <td>{{ rack.group }}</td>
             <td>{{ rack.tenant }}</td>
-            <td>{{ rack.u_height }}</td>
+            <td>{{ rack.get_type_display }}</td>
+            <td>{{ rack.get_width_display }}</td>
+            <td>{{ rack.u_height }}U</td>
         </tr>
     {% endfor %}
 {% endblock %}

+ 2 - 0
netbox/templates/dcim/rack_edit.html

@@ -10,6 +10,8 @@
             {% render_field form.name %}
             {% render_field form.facility_id %}
             {% render_field form.tenant %}
+            {% render_field form.type %}
+            {% render_field form.width %}
             {% render_field form.u_height %}
         </div>
     </div>

+ 11 - 1
netbox/templates/dcim/rack_import.html

@@ -53,6 +53,16 @@
 					<td>Name of tenant (optional)</td>
 					<td>Pied Piper</td>
 				</tr>
+				<tr>
+					<td>Type</td>
+					<td>Rack type (optional)</td>
+					<td>4-post cabinet</td>
+				</tr>
+				<tr>
+					<td>Width</td>
+					<td>Rail-to-rail width (19 or 23 inches)</td>
+					<td>19</td>
+				</tr>
 				<tr>
 					<td>Height</td>
 					<td>Height in rack units</td>
@@ -61,7 +71,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,42</pre>
+		<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,4-post cabinet,19,42</pre>
 	</div>
 </div>
 {% endblock %}

+ 21 - 0
netbox/templates/dcim/rackrole_list.html

@@ -0,0 +1,21 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Rack Role{% endblock %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.dcim.add_rackrole %}
+        <a href="{% url 'dcim:rackrole_add' %}" class="btn btn-primary">
+            <span class="fa fa-plus" aria-hidden="true"></span>
+            Add a rack role
+        </a>
+    {% endif %}
+</div>
+<h1>Rack Roles</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
+    </div>
+</div>
+{% endblock %}

+ 7 - 0
netbox/utilities/forms.py

@@ -27,6 +27,13 @@ def expand_pattern(string):
             yield "{}{}{}".format(lead, i, remnant)
 
 
+def add_blank_choice(choices):
+    """
+    Add a blank choice to the beginning of a choices list.
+    """
+    return ((None, '---------'),) + choices
+
+
 #
 # Widgets
 #

+ 7 - 0
netbox/utilities/tables.py

@@ -28,3 +28,10 @@ class ToggleColumn(tables.CheckBoxColumn):
     @property
     def header(self):
         return mark_safe('<input type="checkbox" name="_all" title="Select all" />')
+
+
+class ColorColumn(tables.Column):
+
+    def render(self, record):
+        html = '<label class="label {}">{}</label>'.format(record.color, record.get_color_display())
+        return mark_safe(html)

+ 5 - 5
requirements.txt

@@ -1,13 +1,14 @@
 cryptography==1.4
-Django==1.9.8
+Django==1.10
 django-debug-toolbar==1.4
 django-filter==0.13.0
-django-rest-swagger==0.3.7
+django-rest-swagger==0.3.10
 django-tables2==1.2.1
-djangorestframework==3.3.3
+djangorestframework==3.4.3
 graphviz==0.4.10
 Markdown==2.6.6
-ncclient==0.4.7
+natsort>=5.0.0
+ncclient==0.5.2
 netaddr==0.7.18
 paramiko==2.0.0
 psycopg2==2.6.1
@@ -15,4 +16,3 @@ py-gfm==0.1.3
 pycrypto==2.6.1
 sqlparse==0.1.19
 xmltodict==0.10.2
-natsort>=5.0.0