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

Merge pull request #357 from digitalocean/develop

Release v1.3.1
Jeremy Stretch 9 лет назад
Родитель
Сommit
8cb38de7d5

+ 7 - 3
CONTRIBUTING.md

@@ -12,8 +12,9 @@ possible that the bug has already been fixed.
 reported. If you think you may be experiencing a reported issue, please add a quick comment to it with a "+1" and a
 quick description of how it's affecting your installation.
 
-* If you're unsure whether the behavior you're seeing is expected, you can join #netbox on irc.freenode.net and ask
-before going through the trouble of submitting an issue report.
+* If you're having trouble installing NetBox, please join #netbox on irc.freenode.net and ask for help before creating
+an issue on GitHub. Many installation problems are simple fixes. The issues list should be reserved for bug reports and
+feature requests.
 
 * When submitting an issue, please be as descriptive as possible. Be sure to describe:
 
@@ -40,12 +41,15 @@ feature creep. For example, the following features would be firmly out of scope
     * Acting as a DNS server
     * Acting as an authentication server
 
+* Feature requests must be very narrowly defined. The more effort you put into writing a feature request, the better its
+chances are of being implemented. Overly broad feature requests will be closed.
+
 * If you're not sure whether the feature you want is a good fit for NetBox, please ask in #netbox on irc.freenode.net.
 Even if it's not quite right for NetBox, we may be able to point you to a tool better suited for the job.
 
 * When submitting a feature request, be sure to include the following:
 
-    * A brief description of the functionality
+    * A detailed description of the functionality
     * A use case for the feature; who would use it and what value it would add to NetBox
     * A rough description of any changes necessary to the database schema (if applicable)
     * Any third-party libraries or other resources which would be involved

+ 3 - 3
netbox/circuits/forms.py

@@ -197,17 +197,17 @@ class CircuitBulkDeleteForm(ConfirmationForm):
 
 def circuit_type_choices():
     type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
-    return [(t.slug, '{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
+    return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
 
 
 def circuit_provider_choices():
     provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
-    return [(p.slug, '{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
+    return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
 
 
 def circuit_site_choices():
     site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
-    return [(s.slug, '{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
 
 
 class CircuitFilterForm(forms.Form, BootstrapMixin):

+ 1 - 1
netbox/circuits/models.py

@@ -80,7 +80,7 @@ class Circuit(CreatedUpdatedModel):
         unique_together = ['provider', 'cid']
 
     def __unicode__(self):
-        return "{0} {1}".format(self.provider, self.cid)
+        return u'{} {}'.format(self.provider, self.cid)
 
     def get_absolute_url(self):
         return reverse('circuits:circuit', args=[self.pk])

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

@@ -61,7 +61,8 @@ urlpatterns = [
     url(r'^interfaces/(?P<pk>\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'),
     url(r'^interfaces/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE},
         name='interface_graphs'),
-    url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection'),
+    url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'),
+    url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'),
 
     # Miscellaneous
     url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),

+ 8 - 0
netbox/dcim/api/views.py

@@ -326,6 +326,14 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
     queryset = InterfaceConnection.objects.all()
 
 
+class InterfaceConnectionListView(generics.ListAPIView):
+    """
+    Retrieve a list of all interface connections
+    """
+    serializer_class = serializers.InterfaceConnectionSerializer
+    queryset = InterfaceConnection.objects.all()
+
+
 #
 # Device bays
 #

+ 17 - 12
netbox/dcim/forms.py

@@ -91,7 +91,7 @@ class RackGroupBulkDeleteForm(ConfirmationForm):
 
 def rackgroup_site_choices():
     site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
-    return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
 
 
 class RackGroupFilterForm(forms.Form, BootstrapMixin):
@@ -175,12 +175,12 @@ class RackBulkDeleteForm(ConfirmationForm):
 
 def rack_site_choices():
     site_choices = Site.objects.annotate(rack_count=Count('racks'))
-    return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
 
 
 def rack_group_choices():
     group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
-    return [(g.pk, '{} ({})'.format(g, g.rack_count)) for g in group_choices]
+    return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
 
 
 class RackFilterForm(forms.Form, BootstrapMixin):
@@ -231,7 +231,7 @@ class DeviceTypeBulkDeleteForm(ConfirmationForm):
 
 def devicetype_manufacturer_choices():
     manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
-    return [(m.slug, '{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
+    return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
 
 
 class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
@@ -373,10 +373,10 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
             for family in [4, 6]:
                 ip_choices = []
                 interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
-                ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
+                ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
                 nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
                     .select_related('nat_inside__interface')
-                ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
+                ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
                 self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
 
         else:
@@ -396,8 +396,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
             self.fields['rack'].choices = []
 
         # Rack position
+        pk = self.instance.pk if self.instance.pk else None
         try:
-            pk = self.instance.pk if self.instance.pk else None
             if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
                 position_choices = Rack.objects.get(pk=self.data['rack'])\
                     .get_rack_units(face=self.data.get('face'), exclude=pk)
@@ -425,6 +425,11 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
         else:
             self.fields['device_type'].choices = []
 
+        # Disable rack assignment if this is a child device installed in a parent device
+        if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
+            self.fields['site'].disabled = True
+            self.fields['rack'].disabled = True
+
 
 class BaseDeviceFromCSVForm(forms.ModelForm):
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
@@ -543,27 +548,27 @@ class DeviceBulkDeleteForm(ConfirmationForm):
 
 def device_site_choices():
     site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
-    return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices]
 
 
 def device_rack_group_choices():
     group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
-    return [(g.pk, '{} ({})'.format(g, g.device_count)) for g in group_choices]
+    return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices]
 
 
 def device_role_choices():
     role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
-    return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices]
+    return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
 
 
 def device_type_choices():
     type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
-    return [(t.pk, '{} ({})'.format(t, t.device_count)) for t in type_choices]
+    return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
 
 
 def device_platform_choices():
     platform_choices = Platform.objects.annotate(device_count=Count('devices'))
-    return [(p.slug, '{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
+    return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
 
 
 class DeviceFilterForm(forms.Form, BootstrapMixin):

+ 41 - 6
netbox/dcim/models.py

@@ -1,7 +1,7 @@
 from collections import OrderedDict
 
 from django.conf import settings
-from django.core.exceptions import ValidationError
+from django.core.exceptions import MultipleObjectsReturned, ValidationError
 from django.core.urlresolvers import reverse
 from django.core.validators import MinValueValidator
 from django.db import models
@@ -9,10 +9,12 @@ from django.db.models import Count, Q, ObjectDoesNotExist
 
 from extras.rpc import RPC_CLIENTS
 from utilities.fields import NullableCharField
+from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
 
 from .fields import ASNField, MACAddressField
 
+
 RACK_FACE_FRONT = 0
 RACK_FACE_REAR = 1
 RACK_FACE_CHOICES = [
@@ -137,6 +139,12 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
     }).order_by(*ordering)
 
 
+class SiteManager(NaturalOrderByManager):
+
+    def get_queryset(self):
+        return self.natural_order_by('name')
+
+
 class Site(CreatedUpdatedModel):
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -150,6 +158,8 @@ class Site(CreatedUpdatedModel):
     shipping_address = models.CharField(max_length=200, blank=True)
     comments = models.TextField(blank=True)
 
+    objects = SiteManager()
+
     class Meta:
         ordering = ['name']
 
@@ -206,12 +216,18 @@ class RackGroup(models.Model):
         ]
 
     def __unicode__(self):
-        return '{} - {}'.format(self.site.name, self.name)
+        return u'{} - {}'.format(self.site.name, self.name)
 
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
 
 
+class RackManager(NaturalOrderByManager):
+
+    def get_queryset(self):
+        return self.natural_order_by('site__name', 'name')
+
+
 class Rack(CreatedUpdatedModel):
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -224,6 +240,8 @@ class Rack(CreatedUpdatedModel):
     u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
     comments = models.TextField(blank=True)
 
+    objects = RackManager()
+
     class Meta:
         ordering = ['site', 'name']
         unique_together = [
@@ -342,6 +360,15 @@ class Rack(CreatedUpdatedModel):
     def get_0u_devices(self):
         return self.devices.filter(position=0)
 
+    def get_utilization(self):
+        """
+        Determine the utilization rate of the rack and return it as a percentage.
+        """
+        if self.u_consumed is None:
+                self.u_consumed = 0
+        u_available = self.u_height - self.u_consumed
+        return int(float(self.u_height - u_available) / self.u_height * 100)
+
 
 #
 # Device Types
@@ -404,7 +431,7 @@ class DeviceType(models.Model):
         ]
 
     def __unicode__(self):
-        return "{} {}".format(self.manufacturer, self.model)
+        return u'{} {}'.format(self.manufacturer, self.model)
 
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
@@ -583,6 +610,12 @@ class Platform(models.Model):
         return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
 
 
+class DeviceManager(NaturalOrderByManager):
+
+    def get_queryset(self):
+        return self.natural_order_by('name')
+
+
 class Device(CreatedUpdatedModel):
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -612,6 +645,8 @@ class Device(CreatedUpdatedModel):
                                        blank=True, null=True, verbose_name='Primary IPv6')
     comments = models.TextField(blank=True)
 
+    objects = DeviceManager()
+
     class Meta:
         ordering = ['name']
         unique_together = ['rack', 'position', 'face']
@@ -922,8 +957,8 @@ class Interface(models.Model):
                 return connection.interface_a
         except InterfaceConnection.DoesNotExist:
             return None
-        except InterfaceConnection.MultipleObjectsReturned as e:
-            raise e("Multiple connections found for {0} interface {1}!".format(self.device, self))
+        except InterfaceConnection.MultipleObjectsReturned:
+            raise MultipleObjectsReturned("Multiple connections found for {} interface {}!".format(self.device, self))
 
 
 class InterfaceConnection(models.Model):
@@ -965,7 +1000,7 @@ class DeviceBay(models.Model):
         unique_together = ['device', 'name']
 
     def __unicode__(self):
-        return '{} - {}'.format(self.device.name, self.name)
+        return u'{} - {}'.format(self.device.name, self.name)
 
     def clean(self):
 

+ 7 - 0
netbox/dcim/tables.py

@@ -48,6 +48,11 @@ STATUS_ICON = """
 {% endif %}
 """
 
+UTILIZATION_GRAPH = """
+{% load helpers %}
+{% utilization_graph record.get_utilization %}
+"""
+
 
 #
 # Sites
@@ -97,6 +102,8 @@ class RackTable(BaseTable):
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
     u_height = tables.Column(verbose_name='Height (U)')
+    u_consumed = tables.Column(accessor=Accessor('u_consumed'), verbose_name='Used (U)')
+    utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
 
     class Meta(BaseTable.Meta):

+ 2 - 2
netbox/dcim/views.py

@@ -7,7 +7,7 @@ from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
-from django.db.models import Count, ProtectedError
+from django.db.models import Count, ProtectedError, Sum
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
@@ -144,7 +144,7 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class RackListView(ObjectListView):
-    queryset = Rack.objects.select_related('site').annotate(device_count=Count('devices', distinct=True))
+    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'))
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     table = tables.RackTable

+ 10 - 9
netbox/ipam/forms.py

@@ -112,7 +112,7 @@ class AggregateBulkDeleteForm(ConfirmationForm):
 
 def aggregate_rir_choices():
     rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
-    return [(r.slug, '{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
+    return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
 
 
 class AggregateFilterForm(forms.Form, BootstrapMixin):
@@ -266,19 +266,19 @@ def prefix_vrf_choices():
 
 def prefix_site_choices():
     site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
-    return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
 
 
 def prefix_status_choices():
     status_counts = {}
     for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
+    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
 
 
 def prefix_role_choices():
     role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
-    return [(r.slug, '{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
+    return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
 
 
 class PrefixFilterForm(forms.Form, BootstrapMixin):
@@ -455,7 +455,7 @@ class VLANGroupBulkDeleteForm(ConfirmationForm):
 
 def vlangroup_site_choices():
     site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
-    return [(s.slug, '{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
 
 
 class VLANGroupFilterForm(forms.Form, BootstrapMixin):
@@ -529,6 +529,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
 class VLANBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
+    group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
 
@@ -539,24 +540,24 @@ class VLANBulkDeleteForm(ConfirmationForm):
 
 def vlan_site_choices():
     site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
-    return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
+    return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
 
 
 def vlan_group_choices():
     group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
-    return [(g.pk, '{} ({})'.format(g, g.vlan_count)) for g in group_choices]
+    return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
 
 
 def vlan_status_choices():
     status_counts = {}
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
+    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
 
 
 def vlan_role_choices():
     role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
-    return [(r.slug, '{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
+    return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
 
 
 class VLANFilterForm(forms.Form, BootstrapMixin):

+ 2 - 2
netbox/ipam/models.py

@@ -385,7 +385,7 @@ class VLANGroup(models.Model):
         verbose_name_plural = 'VLAN groups'
 
     def __unicode__(self):
-        return '{} - {}'.format(self.site.name, self.name)
+        return u'{} - {}'.format(self.site.name, self.name)
 
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@@ -442,7 +442,7 @@ class VLAN(CreatedUpdatedModel):
 
     @property
     def display_name(self):
-        return u"{} ({})".format(self.vid, self.name)
+        return u'{} ({})'.format(self.vid, self.name)
 
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]

+ 2 - 9
netbox/ipam/tables.py

@@ -11,15 +11,8 @@ RIR_EDIT_LINK = """
 """
 
 UTILIZATION_GRAPH = """
-{% with record.get_utilization as percentage %}
-<div class="progress text-center">
-    {% if percentage < 15 %}<span style="font-size: 12px;">{{ percentage }}%</span>{% endif %}
-    <div class="progress-bar progress-bar-{% if percentage >= 90 %}danger{% elif percentage >= 75 %}warning{% else %}success{% endif %}"
-        role="progressbar" aria-valuenow="{{ percentage }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage }}%">
-        {% if percentage >= 15 %}{{ percentage }}%{% endif %}
-    </div>
-</div>
-{% endwith %}
+{% load helpers %}
+{% utilization_graph record.get_utilization %}
 """
 
 ROLE_EDIT_LINK = """

+ 1 - 1
netbox/ipam/views.py

@@ -565,7 +565,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        for field in ['site', 'status', 'role']:
+        for field in ['site', 'group', 'status', 'role']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
 

+ 1 - 1
netbox/netbox/settings.py

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

+ 4 - 0
netbox/secrets/api/views.py

@@ -28,6 +28,7 @@ class SecretRoleListView(generics.ListAPIView):
     """
     queryset = SecretRole.objects.all()
     serializer_class = serializers.SecretRoleSerializer
+    permission_classes = [IsAuthenticated]
 
 
 class SecretRoleDetailView(generics.RetrieveAPIView):
@@ -36,6 +37,7 @@ class SecretRoleDetailView(generics.RetrieveAPIView):
     """
     queryset = SecretRole.objects.all()
     serializer_class = serializers.SecretRoleSerializer
+    permission_classes = [IsAuthenticated]
 
 
 class SecretListView(generics.GenericAPIView):
@@ -47,6 +49,7 @@ class SecretListView(generics.GenericAPIView):
     serializer_class = serializers.SecretSerializer
     filter_class = SecretFilter
     renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
+    permission_classes = [IsAuthenticated]
 
     def get(self, request, private_key=None):
         queryset = self.filter_queryset(self.get_queryset())
@@ -91,6 +94,7 @@ class SecretDetailView(generics.GenericAPIView):
         .prefetch_related('role__users', 'role__groups')
     serializer_class = serializers.SecretSerializer
     renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
+    permission_classes = [IsAuthenticated]
 
     def get(self, request, pk, private_key=None):
         secret = get_object_or_404(Secret, pk=pk)

+ 8 - 1
netbox/secrets/filters.py

@@ -1,6 +1,7 @@
 import django_filters
 
 from .models import Secret, SecretRole
+from dcim.models import Device
 
 
 class SecretFilter(django_filters.FilterSet):
@@ -15,7 +16,13 @@ class SecretFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Role (slug)',
     )
+    device = django_filters.ModelMultipleChoiceFilter(
+        name='device',
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label='Device (Name)',
+    )
 
     class Meta:
         model = Secret
-        fields = ['name', 'role_id', 'role']
+        fields = ['name', 'role_id', 'role', 'device']

+ 1 - 1
netbox/secrets/forms.py

@@ -103,7 +103,7 @@ class SecretBulkDeleteForm(ConfirmationForm):
 
 def secret_role_choices():
     role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
-    return [(r.slug, '{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
+    return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
 
 
 class SecretFilterForm(forms.Form, BootstrapMixin):

+ 2 - 2
netbox/secrets/models.py

@@ -219,8 +219,8 @@ class Secret(CreatedUpdatedModel):
 
     def __unicode__(self):
         if self.role and self.device:
-            return "{} for {}".format(self.role, self.device)
-        return "Secret"
+            return u'{} for {}'.format(self.role, self.device)
+        return u'Secret'
 
     def get_absolute_url(self):
         return reverse('secrets:secret', args=[self.pk])

+ 26 - 2
netbox/templates/dcim/device_edit.html

@@ -22,8 +22,32 @@
         <div class="panel-body">
             {% render_field form.site %}
             {% render_field form.rack %}
-            {% render_field form.face %}
-            {% render_field form.position %}
+            {% if obj.device_type.is_child_device and obj.parent_bay %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label">Parent device</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            <a href="{% url 'dcim:device' pk=obj.parent_bay.device.pk %}">{{ obj.parent_bay.device }}</a>
+                        </p>
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-md-3 control-label">Parent bay</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            {{ obj.parent_bay.name }}
+                            {% if perms.dcim.change_devicebay %}
+                                <a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" class="btn btn-danger btn-xs">
+                                    <i class="glyphicon glyphicon-remove" aria-hidden="true" title="Remove device"></i> Remove
+                                </a>
+                            {% endif %}
+                        </p>
+                    </div>
+                </div>
+            {% elif not obj.device_type.is_child_device %}
+                {% render_field form.face %}
+                {% render_field form.position %}
+            {% endif %}
         </div>
     </div>
     <div class="panel panel-default">

+ 7 - 0
netbox/templates/utilities/templatetags/utilization_graph.html

@@ -0,0 +1,7 @@
+<div class="progress text-center">
+    {% if utilization < 30 %}<span style="font-size: 12px;">{{ utilization }}%</span>{% endif %}
+    <div class="progress-bar progress-bar-{% if utilization >= danger_threshold %}danger{% elif utilization >= warning_threshold %}warning{% else %}success{% endif %}"
+        role="progressbar" aria-valuenow="{{ utilization }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ utilization }}%">
+        {% if utilization >= 30 %}{{ utilization }}%{% endif %}
+    </div>
+</div>

+ 1 - 1
netbox/utilities/forms.py

@@ -60,7 +60,7 @@ class SelectWithDisabled(forms.Select):
             option_label = option_label['label']
         disabled_html = ' disabled="disabled"' if option_disabled else ''
 
-        return format_html('<option value="{}"{}{}>{}</option>',
+        return format_html(u'<option value="{}"{}{}>{}</option>',
                            option_value,
                            selected_html,
                            disabled_html,

+ 30 - 0
netbox/utilities/managers.py

@@ -0,0 +1,30 @@
+from django.db.models import Manager
+
+
+class NaturalOrderByManager(Manager):
+
+    def natural_order_by(self, *fields):
+        """
+        Attempt to order records naturally by segmenting a field into three parts:
+
+        1. Leading integer (if any)
+        2. Middle portion
+        3. Trailing integer (if any)
+
+        :param fields: The fields on which to order the queryset. The last field in the list will be ordered naturally.
+        """
+        db_table = self.model._meta.db_table
+        primary_field = fields[-1]
+
+        id1 = '_{}_{}1'.format(db_table, primary_field)
+        id2 = '_{}_{}2'.format(db_table, primary_field)
+        id3 = '_{}_{}3'.format(db_table, primary_field)
+
+        queryset = super(NaturalOrderByManager, self).get_queryset().extra(select={
+            id1: "CAST(SUBSTRING({}.{} FROM '^(\d+)') AS integer)".format(db_table, primary_field),
+            id2: "SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field),
+            id3: "CAST(SUBSTRING({}.{} FROM '(\d+)$') AS integer)".format(db_table, primary_field),
+        })
+        ordering = fields[0:-1] + (id1, id2, id3)
+
+        return queryset.order_by(*ordering)

+ 12 - 0
netbox/utilities/templatetags/helpers.py

@@ -95,3 +95,15 @@ def querystring_toggle(request, multi=True, page_key='page', **kwargs):
         return '?' + querystring
     else:
         return ''
+
+
+@register.inclusion_tag('utilities/templatetags/utilization_graph.html')
+def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
+    """
+    Display a horizontal bar graph indicating a percentage of utilization.
+    """
+    return {
+        'utilization': utilization,
+        'warning_threshold': warning_threshold,
+        'danger_threshold': danger_threshold,
+    }

+ 7 - 7
netbox/utilities/views.py

@@ -134,12 +134,12 @@ class ObjectEditView(View):
             obj_created = not obj.pk
             obj.save()
 
-            msg = 'Created ' if obj_created else 'Modified '
+            msg = u'Created ' if obj_created else u'Modified '
             msg += self.model._meta.verbose_name
             if hasattr(obj, 'get_absolute_url'):
-                msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), obj)
+                msg = u'{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), obj)
             else:
-                msg = '{} {}'.format(msg, obj)
+                msg = u'{} {}'.format(msg, obj)
             messages.success(request, msg)
             if obj_created:
                 UserAction.objects.log_create(request.user, obj, msg)
@@ -192,7 +192,7 @@ class ObjectDeleteView(View):
         if form.is_valid():
             try:
                 obj.delete()
-                msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
+                msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
                 messages.success(request, msg)
                 UserAction.objects.log_delete(request.user, obj, msg)
                 return redirect(self.redirect_url)
@@ -234,7 +234,7 @@ class BulkImportView(View):
 
                 obj_table = self.table(new_objs)
                 if new_objs:
-                    msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
+                    msg = u'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
                     messages.success(request, msg)
                     UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
 
@@ -281,7 +281,7 @@ class BulkEditView(View):
             if form.is_valid():
                 updated_count = self.update_objects(pk_list, form)
                 if updated_count:
-                    msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
+                    msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
                     messages.success(self.request, msg)
                     UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
                 return redirect(redirect_url)
@@ -345,7 +345,7 @@ class BulkDeleteView(View):
                     handle_protectederror(list(queryset), request, e)
                     return redirect(redirect_url)
 
-                msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
+                msg = u'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
                 messages.success(request, msg)
                 UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
                 return redirect(redirect_url)

+ 1 - 1
requirements.txt

@@ -1,5 +1,5 @@
 cryptography==1.4
-Django==1.9.7
+Django==1.9.8
 django-debug-toolbar==1.4
 django-filter==0.13.0
 django-rest-swagger==0.3.7