Преглед изворни кода

Merge pull request #453 from digitalocean/develop

Release v1.5.0
Jeremy Stretch пре 9 година
родитељ
комит
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.
 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
 # 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.
 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
 ### 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.
 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
 ### 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
 ### Components
 
 

BIN
docs/netbox_logo.png


+ 2 - 2
netbox/circuits/admin.py

@@ -21,8 +21,8 @@ class CircuitTypeAdmin(admin.ModelAdmin):
 
 
 @admin.register(Circuit)
 @admin.register(Circuit)
 class CircuitAdmin(admin.ModelAdmin):
 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']
     list_filter = ['provider', 'type', 'tenant']
     exclude = ['interface']
     exclude = ['interface']
 
 

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

@@ -53,7 +53,7 @@ class CircuitSerializer(serializers.ModelSerializer):
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
         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):
 class CircuitNestedSerializer(CircuitSerializer):

+ 3 - 3
netbox/circuits/forms.py

@@ -102,7 +102,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
         model = Circuit
         model = Circuit
         fields = [
         fields = [
             'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
             '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 = {
         help_texts = {
             'cid': "Unique circuit ID",
             'cid': "Unique circuit ID",
@@ -169,8 +169,8 @@ class CircuitFromCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Circuit
         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):
 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)
     interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
     port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
     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)')
     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')
     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)')
     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.site.name,
             self.install_date.isoformat() if self.install_date else '',
             self.install_date.isoformat() if self.install_date else '',
             str(self.port_speed),
             str(self.port_speed),
+            str(self.upstream_speed),
             str(self.commit_rate) if self.commit_rate else '',
             str(self.commit_rate) if self.commit_rate else '',
             self.xconnect_id,
             self.xconnect_id,
             self.pp_info,
             self.pp_info,
@@ -116,12 +119,18 @@ class Circuit(CreatedUpdatedModel):
         else:
         else:
             return '{} Kbps'.format(speed)
             return '{} Kbps'.format(speed)
 
 
-    @property
     def port_speed_human(self):
     def port_speed_human(self):
         return self._humanize_speed(self.port_speed)
         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):
     def commit_rate_human(self):
         if not self.commit_rate:
         if not self.commit_rate:
             return ''
             return ''
         return self._humanize_speed(self.commit_rate)
         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 (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
     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)
 @admin.register(Rack)
 class RackAdmin(admin.ModelAdmin):
 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 ipam.models import IPAddress
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
     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
 from tenancy.api.serializers import TenantNestedSerializer
 
 
@@ -46,6 +46,23 @@ class RackGroupNestedSerializer(RackGroupSerializer):
         fields = ['id', 'name', 'slug']
         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
 # Racks
 #
 #
@@ -55,10 +72,12 @@ class RackSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     site = SiteNestedSerializer()
     group = RackGroupNestedSerializer()
     group = RackGroupNestedSerializer()
     tenant = TenantNestedSerializer()
     tenant = TenantNestedSerializer()
+    role = RackRoleNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = Rack
         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):
 class RackNestedSerializer(RackSerializer):
@@ -72,8 +91,8 @@ class RackDetailSerializer(RackSerializer):
     rear_units = serializers.SerializerMethodField()
     rear_units = serializers.SerializerMethodField()
 
 
     class Meta(RackSerializer.Meta):
     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):
     def get_front_units(self, obj):
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -384,6 +403,25 @@ class DeviceBayDetailSerializer(DeviceBaySerializer):
         fields = ['id', 'device', 'name', 'installed_device']
         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
 # 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/$', RackGroupListView.as_view(), name='rackgroup_list'),
     url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
     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
     # Racks
     url(r'^racks/$', RackListView.as_view(), name='rack_list'),
     url(r'^racks/$', RackListView.as_view(), name='rack_list'),
     url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
     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+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
     url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
     url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
     url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
     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
     # Console ports
     url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
     url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),

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

@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404
 
 
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
     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 dcim import filters
 from .exceptions import MissingFilterException
 from .exceptions import MissingFilterException
@@ -60,6 +60,26 @@ class RackGroupDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.RackGroupSerializer
     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
 # Racks
 #
 #
@@ -349,18 +369,23 @@ class DeviceBayListView(generics.ListAPIView):
     def get_queryset(self):
     def get_queryset(self):
 
 
         device = get_object_or_404(Device, pk=self.kwargs['pk'])
         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 (
 from .models import (
     ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
     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
 from tenancy.models import Tenant
 
 
@@ -96,6 +96,17 @@ class RackFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (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:
     class Meta:
         model = Rack
         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.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
+    APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
 )
 )
 
 
@@ -15,7 +15,8 @@ from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
     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
     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
 # Sites
 #
 #
@@ -123,6 +148,18 @@ class RackGroupFilterForm(forms.Form, BootstrapMixin):
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
 
 
 
 
+#
+# Rack roles
+#
+
+class RackRoleForm(forms.ModelForm, BootstrapMixin):
+    slug = SlugField()
+
+    class Meta:
+        model = RackRole
+        fields = ['name', 'slug', 'color']
+
+
 #
 #
 # Racks
 # Racks
 #
 #
@@ -135,7 +172,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = Rack
         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 = {
         help_texts = {
             'site': "The site at which the rack exists",
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
             'name': "Organizational rack name",
@@ -165,10 +202,13 @@ class RackFromCSVForm(forms.ModelForm):
     group_name = forms.CharField(required=False)
     group_name = forms.CharField(required=False)
     tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
     tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
                                     error_messages={'invalid_choice': 'Tenant not found.'})
                                     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:
     class Meta:
         model = Rack
         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):
     def clean(self):
 
 
@@ -182,6 +222,19 @@ class RackFromCSVForm(forms.ModelForm):
             except RackGroup.DoesNotExist:
             except RackGroup.DoesNotExist:
                 self.add_error('group_name', "Invalid rack group ({})".format(group))
                 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):
 class RackImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=RackFromCSVForm)
     csv = CSVDataField(csv_form=RackFromCSVForm)
@@ -189,9 +242,12 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
 
 
 class RackBulkEditForm(forms.Form, BootstrapMixin):
 class RackBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
     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')
     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)')
     u_height = forms.IntegerField(required=False, label='Height (U)')
     comments = CommentField()
     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]
     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):
 class RackFilterForm(forms.Form, BootstrapMixin):
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
@@ -218,6 +279,8 @@ class RackFilterForm(forms.Form, BootstrapMixin):
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
     tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
     tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
                                        widget=forms.SelectMultiple(attrs={'size': 8}))
                                        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
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
 
         # Limit rack choices
         # 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'])
             self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
         elif self.initial.get('site'):
         elif self.initial.get('site'):
             self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['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'):
         if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
             self.fields['site'].disabled = True
             self.fields['site'].disabled = True
             self.fields['rack'].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):
 class BaseDeviceFromCSVForm(forms.ModelForm):
@@ -1254,4 +1319,4 @@ class ModuleForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = Module
         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.conf import settings
 from django.core.exceptions import MultipleObjectsReturned, ValidationError
 from django.core.exceptions import MultipleObjectsReturned, ValidationError
 from django.core.urlresolvers import reverse
 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 import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 from django.db.models import Count, Q, ObjectDoesNotExist
 
 
@@ -16,6 +16,26 @@ from utilities.models import CreatedUpdatedModel
 from .fields import ASNField, MACAddressField
 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_FRONT = 0
 RACK_FACE_REAR = 1
 RACK_FACE_REAR = 1
 RACK_FACE_CHOICES = [
 RACK_FACE_CHOICES = [
@@ -41,7 +61,7 @@ COLOR_RED = 'red'
 COLOR_GRAY1 = 'light_gray'
 COLOR_GRAY1 = 'light_gray'
 COLOR_GRAY2 = 'medium_gray'
 COLOR_GRAY2 = 'medium_gray'
 COLOR_GRAY3 = 'dark_gray'
 COLOR_GRAY3 = 'dark_gray'
-DEVICE_ROLE_COLOR_CHOICES = [
+ROLE_COLOR_CHOICES = [
     [COLOR_TEAL, 'Teal'],
     [COLOR_TEAL, 'Teal'],
     [COLOR_GREEN, 'Green'],
     [COLOR_GREEN, 'Green'],
     [COLOR_BLUE, 'Blue'],
     [COLOR_BLUE, 'Blue'],
@@ -183,6 +203,10 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
     }).order_by(*ordering)
     }).order_by(*ordering)
 
 
 
 
+#
+# Sites
+#
+
 class SiteManager(NaturalOrderByManager):
 class SiteManager(NaturalOrderByManager):
 
 
     def get_queryset(self):
     def get_queryset(self):
@@ -244,6 +268,10 @@ class Site(CreatedUpdatedModel):
         return self.circuits.count()
         return self.circuits.count()
 
 
 
 
+#
+# Racks
+#
+
 class RackGroup(models.Model):
 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
     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)
         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):
 class RackManager(NaturalOrderByManager):
 
 
     def get_queryset(self):
     def get_queryset(self):
@@ -284,7 +330,12 @@ class Rack(CreatedUpdatedModel):
     site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
     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)
     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)
     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)
     comments = models.TextField(blank=True)
 
 
     objects = RackManager()
     objects = RackManager()
@@ -320,6 +371,9 @@ class Rack(CreatedUpdatedModel):
             self.name,
             self.name,
             self.facility_id or '',
             self.facility_id or '',
             self.tenant.name if self.tenant else '',
             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),
             str(self.u_height),
         ])
         ])
 
 
@@ -627,7 +681,7 @@ class DeviceRole(models.Model):
     """
     """
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -1073,6 +1127,8 @@ class Module(models.Model):
     device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
     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)
     parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
     name = models.CharField(max_length=50, verbose_name='Name')
     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)
     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)
     serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
     discovered = models.BooleanField(default=False, verbose_name='Discovered')
     discovered = models.BooleanField(default=False, verbose_name='Discovered')

+ 29 - 4
netbox/dcim/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, ColorColumn, ToggleColumn
 
 
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
@@ -22,6 +22,12 @@ RACKGROUP_ACTIONS = """
 {% endif %}
 {% 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 = """
 DEVICEROLE_ACTIONS = """
 {% if perms.dcim.change_devicerole %}
 {% 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>
     <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')
         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
 # Racks
 #
 #
@@ -105,6 +129,7 @@ class RackTable(BaseTable):
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
     facility_id = tables.Column(verbose_name='Facility ID')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     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')
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
     u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
     u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
@@ -112,7 +137,7 @@ class RackTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         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')
                   'utilization')
 
 
 
 
@@ -233,14 +258,14 @@ class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
     device_count = tables.Column(verbose_name='Devices')
+    color = ColorColumn(verbose_name='Color')
     slug = tables.Column(verbose_name='Slug')
     slug = tables.Column(verbose_name='Slug')
-    color = tables.Column(verbose_name='Color')
     actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
     actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
                                     verbose_name='')
                                     verbose_name='')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = DeviceRole
         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',
         'site',
         'group',
         'group',
         'tenant',
         'tenant',
+        'role',
+        'type',
+        'width',
         'u_height',
         'u_height',
         'comments'
         'comments'
     ]
     ]
@@ -118,6 +121,9 @@ class RackTest(APITestCase):
         'site',
         'site',
         'group',
         'group',
         'tenant',
         'tenant',
+        'role',
+        'type',
+        'width',
         'u_height',
         'u_height',
         'comments'
         'comments'
     ]
     ]
@@ -130,6 +136,9 @@ class RackTest(APITestCase):
         'site',
         'site',
         'group',
         'group',
         'tenant',
         'tenant',
+        'role',
+        'type',
+        'width',
         'u_height',
         'u_height',
         'comments',
         'comments',
         'front_units',
         'front_units',

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

@@ -13,59 +13,67 @@ class DeviceTestCase(TestCase):
 
 
     def test_racked_device(self):
     def test_racked_device(self):
         test = DeviceForm(data={
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'leaf-switch'),
             'name': 'test',
             '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'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': RACK_FACE_FRONT,
             'face': RACK_FACE_FRONT,
-            'platform': get_id(Platform, 'juniper-junos'),
-            'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'position': 41,
             '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.is_valid(), test.fields['position'].choices)
         self.assertTrue(test.save())
         self.assertTrue(test.save())
 
 
     def test_racked_device_occupied(self):
     def test_racked_device_occupied(self):
         test = DeviceForm(data={
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'leaf-switch'),
             'name': 'test',
             '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'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': RACK_FACE_FRONT,
             'face': RACK_FACE_FRONT,
-            'platform': get_id(Platform, 'juniper-junos'),
-            'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'position': 1,
             'position': 1,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'juniper'),
+            'platform': get_id(Platform, 'juniper-junos'),
+            'status': STATUS_ACTIVE,
         })
         })
         self.assertFalse(test.is_valid())
         self.assertFalse(test.is_valid())
 
 
     def test_non_racked_device(self):
     def test_non_racked_device(self):
         test = DeviceForm(data={
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'pdu'),
             'name': 'test',
             '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'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': None,
             'face': None,
-            'platform': None,
-            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'position': None,
             'position': None,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'servertech'),
+            'platform': None,
+            'status': STATUS_ACTIVE,
         })
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())
         self.assertTrue(test.save())
 
 
     def test_non_racked_device_with_face(self):
     def test_non_racked_device_with_face(self):
         test = DeviceForm(data={
         test = DeviceForm(data={
-            'device_role': get_id(DeviceRole, 'pdu'),
             'name': 'test',
             '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'),
             'site': get_id(Site, 'test1'),
+            'rack': '1',
             'face': RACK_FACE_REAR,
             'face': RACK_FACE_REAR,
-            'platform': None,
-            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'position': None,
             'position': None,
-            'rack': '1',
-            'manufacturer': get_id(Manufacturer, 'servertech'),
+            'platform': None,
+            'status': STATUS_ACTIVE,
         })
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())
         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/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
     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
     # Racks
     url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
     url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
     url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
     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.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.db.models import Count, Sum
 from django.db.models import Count, Sum
+from django.db.models.functions import Coalesce
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.http import urlencode
 from django.utils.http import urlencode
@@ -26,7 +27,7 @@ from .models import (
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    Site,
+    RackRole, Site,
 )
 )
 
 
 
 
@@ -137,7 +138,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 #
 #
 
 
 class RackGroupListView(ObjectListView):
 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 = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     filter_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
     table = tables.RackGroupTable
@@ -149,6 +150,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_rackgroup'
     permission_required = 'dcim.change_rackgroup'
     model = RackGroup
     model = RackGroup
     form_class = forms.RackGroupForm
     form_class = forms.RackGroupForm
+    success_url = 'dcim:rackgroup_list'
     cancel_url = 'dcim:rackgroup_list'
     cancel_url = 'dcim:rackgroup_list'
 
 
 
 
@@ -158,13 +160,39 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_redirect_url = 'dcim:rackgroup_list'
     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
 # Racks
 #
 #
 
 
 class RackListView(ObjectListView):
 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 = filters.RackFilter
     filter_form = forms.RackFilterForm
     filter_form = forms.RackFilterForm
     table = tables.RackTable
     table = tables.RackTable
@@ -223,11 +251,12 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
     def update_objects(self, pk_list, form):
 
 
         fields_to_update = {}
         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]:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
                 fields_to_update[field] = form.cleaned_data[field]
 
 
@@ -533,8 +562,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class DeviceListView(ObjectListView):
 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 = filters.DeviceFilter
     filter_form = forms.DeviceFilterForm
     filter_form = forms.DeviceFilterForm
     table = tables.DeviceTable
     table = tables.DeviceTable
@@ -680,7 +709,8 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 def device_inventory(request, pk):
 def device_inventory(request, pk):
 
 
     device = get_object_or_404(Device, pk=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', {
     return render(request, 'dcim/device_inventory.html', {
         'device': device,
         'device': device,

+ 2 - 6
netbox/ipam/forms.py

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

+ 7 - 6
netbox/ipam/models.py

@@ -254,12 +254,13 @@ class Prefix(CreatedUpdatedModel):
 
 
     def clean(self):
     def clean(self):
         # Disallow host masks
         # 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):
     def save(self, *args, **kwargs):
         if self.prefix:
         if self.prefix:

+ 13 - 3
netbox/ipam/tables.py

@@ -43,12 +43,22 @@ IPADDRESS_LINK = """
 {% if record.pk %}
 {% if record.pk %}
     <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
     <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
 {% elif perms.ipam.add_ipaddress %}
 {% 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 %}
 {% else %}
     {{ record.0 }}
     {{ record.0 }}
 {% endif %}
 {% 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 = """
 STATUS_LABEL = """
 {% if record.pk %}
 {% if record.pk %}
     <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
     <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
@@ -149,7 +159,7 @@ class PrefixTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
     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')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     role = tables.Column(verbose_name='Role')
     role = tables.Column(verbose_name='Role')
@@ -183,7 +193,7 @@ class PrefixBriefTable(BaseTable):
 class IPAddressTable(BaseTable):
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
     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')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
     device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
                                verbose_name='Device')
                                verbose_name='Device')

+ 5 - 4
netbox/ipam/views.py

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

+ 1 - 2
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.4.2'
+VERSION = '1.5.0'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -172,7 +172,6 @@ MESSAGE_TAGS = {
 # Authentication URLs
 # Authentication URLs
 LOGIN_URL = '/login/'
 LOGIN_URL = '/login/'
 LOGIN_REDIRECT_URL = '/'
 LOGIN_REDIRECT_URL = '/'
-LOGOUT_URL = '/logout/'
 
 
 # Secrets
 # Secrets
 SECRETS_MIN_PUBKEY_SIZE = 2048
 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 */
     margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
     padding-bottom: 30px;
     padding-bottom: 30px;
 }
 }
+.navbar-brand {
+    padding: 12px 15px 8px;
+}
 .footer, .push {
 .footer, .push {
     height: 60px; /* .push must be the same height as .footer */
     height: 60px; /* .push must be the same height as .footer */
 }
 }
@@ -291,27 +294,39 @@ ul.rack_near_face li.empty:hover a {
     display: block;
     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 */
 /* Misc */
 .banner-bottom {
 .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 '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 'jquery-ui-1.11.4/jquery-ui.css' %}">
 	<link rel="stylesheet" href="{% static 'css/base.css' %}">
 	<link rel="stylesheet" href="{% static 'css/base.css' %}">
+    <link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
 </head>
 </head>
 <body>
 <body>
     <nav class="navbar navbar-default navbar-fixed-top">
     <nav class="navbar navbar-default navbar-fixed-top">
@@ -19,7 +20,9 @@
                     <span class="icon-bar"></span>
                     <span class="icon-bar"></span>
                     <span class="icon-bar"></span>
                     <span class="icon-bar"></span>
                 </button>
                 </button>
-                <a class="navbar-brand" href="/">NetBox</a>
+                <a class="navbar-brand" href="/">
+                    <img src="{% static 'img/netbox_logo.png' %}" />
+                </a>
             </div>
             </div>
             <div id="navbar" class="navbar-collapse collapse">
             <div id="navbar" class="navbar-collapse collapse">
                 {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
                 {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
@@ -58,6 +61,11 @@
                             {% if perms.dcim.add_rackgroup %}
                             {% 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>
                                 <li><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
                             {% endif %}
                             {% 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>
                         </ul>
                     </li>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">
                     <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>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
-                    <td>Port Speed</td>
+                    <td>Speed</td>
                     <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 %}
                         {% else %}
-                            <span class="text-muted">N/A</span>
+                            {{ circuit.port_speed_human }}
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>

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

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

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

@@ -60,8 +60,13 @@
 				</tr>
 				</tr>
 				<tr>
 				<tr>
 					<td>Port Speed</td>
 					<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>
 				<tr>
 				<tr>
 					<td>Commit rate</td>
 					<td>Commit rate</td>
@@ -81,7 +86,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<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>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -36,6 +36,11 @@
 					<td>Functional role of device</td>
 					<td>Functional role of device</td>
 					<td>Blade Server</td>
 					<td>Blade Server</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Device manufacturer</td>
 					<td>Device manufacturer</td>
 					<td>Hardware manufacturer</td>
 					<td>Hardware manufacturer</td>
@@ -69,7 +74,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<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>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

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

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

@@ -96,6 +96,20 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </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>
                 <tr>
                     <td>Height</td>
                     <td>Height</td>
                     <td>{{ rack.u_height }}U</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 title %}Rack Bulk Edit{% endblock %}
 
 
 {% block select_objects_table %}
 {% 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 %}
     {% for rack in selected_objects %}
         <tr>
         <tr>
             <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
             <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
-            <td>{{ rack.facility_id }}</td>
             <td>{{ rack.site }}</td>
             <td>{{ rack.site }}</td>
+            <td>{{ rack.group }}</td>
             <td>{{ rack.tenant }}</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>
         </tr>
     {% endfor %}
     {% endfor %}
 {% endblock %}
 {% endblock %}

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

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

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

@@ -53,6 +53,16 @@
 					<td>Name of tenant (optional)</td>
 					<td>Name of tenant (optional)</td>
 					<td>Pied Piper</td>
 					<td>Pied Piper</td>
 				</tr>
 				</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>
 				<tr>
 					<td>Height</td>
 					<td>Height</td>
 					<td>Height in rack units</td>
 					<td>Height in rack units</td>
@@ -61,7 +71,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<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>
 </div>
 </div>
 {% endblock %}
 {% 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)
             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
 # Widgets
 #
 #

+ 7 - 0
netbox/utilities/tables.py

@@ -28,3 +28,10 @@ class ToggleColumn(tables.CheckBoxColumn):
     @property
     @property
     def header(self):
     def header(self):
         return mark_safe('<input type="checkbox" name="_all" title="Select all" />')
         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
 cryptography==1.4
-Django==1.9.8
+Django==1.10
 django-debug-toolbar==1.4
 django-debug-toolbar==1.4
 django-filter==0.13.0
 django-filter==0.13.0
-django-rest-swagger==0.3.7
+django-rest-swagger==0.3.10
 django-tables2==1.2.1
 django-tables2==1.2.1
-djangorestframework==3.3.3
+djangorestframework==3.4.3
 graphviz==0.4.10
 graphviz==0.4.10
 Markdown==2.6.6
 Markdown==2.6.6
-ncclient==0.4.7
+natsort>=5.0.0
+ncclient==0.5.2
 netaddr==0.7.18
 netaddr==0.7.18
 paramiko==2.0.0
 paramiko==2.0.0
 psycopg2==2.6.1
 psycopg2==2.6.1
@@ -15,4 +16,3 @@ py-gfm==0.1.3
 pycrypto==2.6.1
 pycrypto==2.6.1
 sqlparse==0.1.19
 sqlparse==0.1.19
 xmltodict==0.10.2
 xmltodict==0.10.2
-natsort>=5.0.0