Merge pull request #357 from digitalocean/develop

Release v1.3.1
This commit is contained in:
Jeremy Stretch 2016-07-21 11:48:40 -04:00 committed by GitHub
commit 8cb38de7d5
25 changed files with 203 additions and 65 deletions

View File

@ -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 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. 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 * If you're having trouble installing NetBox, please join #netbox on irc.freenode.net and ask for help before creating
before going through the trouble of submitting an issue report. 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: * 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 a DNS server
* Acting as an authentication 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. * 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. 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: * 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 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) * A rough description of any changes necessary to the database schema (if applicable)
* Any third-party libraries or other resources which would be involved * Any third-party libraries or other resources which would be involved

View File

@ -197,17 +197,17 @@ class CircuitBulkDeleteForm(ConfirmationForm):
def circuit_type_choices(): def circuit_type_choices():
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits')) 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(): def circuit_provider_choices():
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits')) 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(): def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits')) 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): class CircuitFilterForm(forms.Form, BootstrapMixin):

View File

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

View File

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

View File

@ -326,6 +326,14 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
queryset = InterfaceConnection.objects.all() 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 # Device bays
# #

View File

@ -91,7 +91,7 @@ class RackGroupBulkDeleteForm(ConfirmationForm):
def rackgroup_site_choices(): def rackgroup_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('rack_groups')) 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): class RackGroupFilterForm(forms.Form, BootstrapMixin):
@ -175,12 +175,12 @@ class RackBulkDeleteForm(ConfirmationForm):
def rack_site_choices(): def rack_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('racks')) 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(): def rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) 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): class RackFilterForm(forms.Form, BootstrapMixin):
@ -231,7 +231,7 @@ class DeviceTypeBulkDeleteForm(ConfirmationForm):
def devicetype_manufacturer_choices(): def devicetype_manufacturer_choices():
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) 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): class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
@ -373,10 +373,10 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
for family in [4, 6]: for family in [4, 6]:
ip_choices = [] ip_choices = []
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance) 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)\ nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
.select_related('nat_inside__interface') .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 self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
else: else:
@ -396,8 +396,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
self.fields['rack'].choices = [] self.fields['rack'].choices = []
# Rack position # Rack position
pk = self.instance.pk if self.instance.pk else None
try: 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')): if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
position_choices = Rack.objects.get(pk=self.data['rack'])\ position_choices = Rack.objects.get(pk=self.data['rack'])\
.get_rack_units(face=self.data.get('face'), exclude=pk) .get_rack_units(face=self.data.get('face'), exclude=pk)
@ -425,6 +425,11 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
else: else:
self.fields['device_type'].choices = [] 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): class BaseDeviceFromCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name', device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
@ -543,27 +548,27 @@ class DeviceBulkDeleteForm(ConfirmationForm):
def device_site_choices(): def device_site_choices():
site_choices = Site.objects.annotate(device_count=Count('racks__devices')) 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(): def device_rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices')) 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(): def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices')) 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(): def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances')) 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(): def device_platform_choices():
platform_choices = Platform.objects.annotate(device_count=Count('devices')) 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): class DeviceFilterForm(forms.Form, BootstrapMixin):

View File

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

View File

@ -48,6 +48,11 @@ STATUS_ICON = """
{% endif %} {% endif %}
""" """
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph record.get_utilization %}
"""
# #
# Sites # Sites
@ -97,6 +102,8 @@ 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')
u_height = tables.Column(verbose_name='Height (U)') 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') devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):

View File

@ -7,7 +7,7 @@ from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin 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, ProtectedError from django.db.models import Count, ProtectedError, Sum
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
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
@ -144,7 +144,7 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class RackListView(ObjectListView): 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 = filters.RackFilter
filter_form = forms.RackFilterForm filter_form = forms.RackFilterForm
table = tables.RackTable table = tables.RackTable

View File

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

View File

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

View File

@ -11,15 +11,8 @@ RIR_EDIT_LINK = """
""" """
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% with record.get_utilization as percentage %} {% load helpers %}
<div class="progress text-center"> {% utilization_graph record.get_utilization %}
{% 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 %}
""" """
ROLE_EDIT_LINK = """ ROLE_EDIT_LINK = """

View File

@ -565,7 +565,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
def update_objects(self, pk_list, form): def update_objects(self, pk_list, form):
fields_to_update = {} fields_to_update = {}
for field in ['site', 'status', 'role']: for field in ['site', 'group', 'status', 'role']:
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]

View File

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

View File

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

View File

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

View File

@ -103,7 +103,7 @@ class SecretBulkDeleteForm(ConfirmationForm):
def secret_role_choices(): def secret_role_choices():
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets')) 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): class SecretFilterForm(forms.Form, BootstrapMixin):

View File

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

View File

@ -22,8 +22,32 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.site %} {% render_field form.site %}
{% render_field form.rack %} {% render_field form.rack %}
{% render_field form.face %} {% if obj.device_type.is_child_device and obj.parent_bay %}
{% render_field form.position %} <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> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -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>

View File

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

View File

@ -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)

View File

@ -95,3 +95,15 @@ def querystring_toggle(request, multi=True, page_key='page', **kwargs):
return '?' + querystring return '?' + querystring
else: else:
return '' 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,
}

View File

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

View File

@ -1,5 +1,5 @@
cryptography==1.4 cryptography==1.4
Django==1.9.7 Django==1.9.8
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.7