From 7a50cd23209888230b672a8454e79ca850aa0042 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 13:50:46 -0400 Subject: [PATCH 01/20] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 69d40a68b..01f266194 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.3.0' +VERSION = '1.3.1-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 0d46a65a364447aeb77feca7b4c1327d35579d29 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 14:48:51 -0400 Subject: [PATCH 02/20] Unicode handling cleanup --- netbox/circuits/models.py | 2 +- netbox/dcim/models.py | 6 +++--- netbox/ipam/models.py | 4 ++-- netbox/secrets/models.py | 4 ++-- netbox/utilities/forms.py | 2 +- netbox/utilities/views.py | 14 +++++++------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index c7c60378e..cd5d760b3 100644 --- a/netbox/circuits/models.py +++ b/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]) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 15df8f85e..feeaf4f28 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -206,7 +206,7 @@ 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) @@ -404,7 +404,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]) @@ -965,7 +965,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): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index e9e6a8011..0aa971ae3 100644 --- a/netbox/ipam/models.py +++ b/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] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index f6f4353c2..80abfcbdf 100644 --- a/netbox/secrets/models.py +++ b/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]) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 9855b9273..3b4e93f10 100644 --- a/netbox/utilities/forms.py +++ b/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('', + return format_html(u'', option_value, selected_html, disabled_html, diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 9b93301b0..29f2de7cf 100644 --- a/netbox/utilities/views.py +++ b/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 = '{} {}'.format(msg, obj.get_absolute_url(), obj) + msg = u'{} {}'.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) From 82ad4790370bfef9f29c3f43370b9d82bfdf1907 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 15:28:36 -0400 Subject: [PATCH 03/20] Enforce authentication for all secrets API views --- netbox/secrets/api/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 629a28300..672165da3 100644 --- a/netbox/secrets/api/views.py +++ b/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) From c9dc6d04ef676604cd06fca166e9ff6459777742 Mon Sep 17 00:00:00 2001 From: Zach Moody Date: Mon, 18 Jul 2016 17:53:47 -0500 Subject: [PATCH 04/20] Fixes #332 - Add device filter to secrets api. --- netbox/secrets/filters.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 0773562e6..4606b3db5 100644 --- a/netbox/secrets/filters.py +++ b/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'] From 783341017fee71c0cad935f4c1542577a40632f4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Jul 2016 11:11:16 -0400 Subject: [PATCH 05/20] Fixes #331: Add group field to VLAN bulk edit form --- netbox/ipam/forms.py | 1 + netbox/ipam/views.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 2c1b24192..5c0c7805f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 7119a8209..ea980c2c6 100644 --- a/netbox/ipam/views.py +++ b/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] From 4f6f032ca2c1949cd584af53f5321df97b4ec60b Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 15 Jul 2016 21:12:35 -0700 Subject: [PATCH 06/20] Update the valid urls, to expose the new api connection listing endpoint. Naming convention updated for both interface connections to match the rest. --- netbox/dcim/api/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 724e244a2..a22a661aa 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -61,7 +61,8 @@ urlpatterns = [ url(r'^interfaces/(?P\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'), url(r'^interfaces/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'), - url(r'^interface-connections/(?P\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection'), + url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'), + url(r'^interface-connections/(?P\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'), # Miscellaneous url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'), From c65b9fcb0b5ddd1f3f843f2573bf5b54a0bdcc65 Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 15 Jul 2016 21:13:15 -0700 Subject: [PATCH 07/20] Add an api endpoint for listing all connections --- netbox/dcim/api/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d573cddde..27b908d56 100644 --- a/netbox/dcim/api/views.py +++ b/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 # From c643e3a74f6718fd4c5a98ee0ad1da8e096582ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Jul 2016 13:09:15 -0400 Subject: [PATCH 08/20] Fixes #327: Disable rack assignment for installed child devices --- netbox/dcim/forms.py | 5 +++++ netbox/templates/dcim/device_edit.html | 28 ++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4954099dd..8b0c9cce2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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 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', diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 2694bad7a..b0142dcb0 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -22,8 +22,32 @@
{% 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 %} +
+ + +
+
+ +
+

+ {{ obj.parent_bay.name }} + {% if perms.dcim.change_devicebay %} + + Remove + + {% endif %} +

+
+
+ {% elif not obj.device_type.is_child_device %} + {% render_field form.face %} + {% render_field form.position %} + {% endif %}
From b8d7dd170efc1b07bc909391d8dde7b3dfb3b116 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Jul 2016 10:07:32 -0400 Subject: [PATCH 09/20] #303: First stab at implementing a natural ordering for sites, racks, and devices --- netbox/dcim/models.py | 26 ++++++++++++++++++++++++++ netbox/dcim/views.py | 2 +- netbox/utilities/managers.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 netbox/utilities/managers.py diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index feeaf4f28..64028dab3 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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'] @@ -212,6 +222,12 @@ class RackGroup(models.Model): 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 = [ @@ -583,6 +601,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 +636,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'] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2fdbbd0a7..d59d5496b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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', 'group').annotate(device_count=Count('devices', distinct=True)) filter = filters.RackFilter filter_form = forms.RackFilterForm table = tables.RackTable diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py new file mode 100644 index 000000000..1235a21d1 --- /dev/null +++ b/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) From 19d7caf1dae9f7efc6c03ff1f0a7e24418c17661 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Jul 2016 10:10:31 -0400 Subject: [PATCH 10/20] Corrects a device_type error introduced in c643e3a74f6718fd4c5a98ee0ad1da8e096582ea --- netbox/dcim/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8b0c9cce2..c8f7eca3f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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) @@ -426,7 +426,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin): self.fields['device_type'].choices = [] # Disable rack assignment if this is a child device installed in a parent device - if 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['rack'].disabled = True From 0bd2aa928980579707561dc837dd4fe0a6f892eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Jul 2016 11:50:32 -0400 Subject: [PATCH 11/20] Updated the CONTRIBUTING guide --- CONTRIBUTING.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58de11608..f9434e32c 100644 --- a/CONTRIBUTING.md +++ b/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 From 5c59677c575454fea10925d0e0ac1b941a0066eb Mon Sep 17 00:00:00 2001 From: bellwood Date: Wed, 20 Jul 2016 13:04:11 -0400 Subject: [PATCH 12/20] properly support #304 support for #304 --- netbox/dcim/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d59d5496b..5f8434bcc 100644 --- a/netbox/dcim/views.py +++ b/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', 'group').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 From 3f94295d7ec4863e2b46bf871b7c5f83512eee9e Mon Sep 17 00:00:00 2001 From: bellwood Date: Wed, 20 Jul 2016 13:22:20 -0400 Subject: [PATCH 13/20] support for #304 support for #304 --- netbox/dcim/tables.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index dc66c3ab1..65a1cbbb3 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -48,6 +48,18 @@ STATUS_ICON = """ {% endif %} """ +UTILIZATION_GRAPH = """ +{% with record.get_utilization as percentage %} +
+ {% if percentage < 15 %}{{ percentage }}%{% endif %} +
+ {% if percentage >= 15 %}{{ percentage }}%{% endif %} +
+
+{% endwith %} +""" + # # Sites From 6fe40ef2232a9a67706ab43692bc289e5a98081e Mon Sep 17 00:00:00 2001 From: bellwood Date: Wed, 20 Jul 2016 13:23:49 -0400 Subject: [PATCH 14/20] support for #304 --- netbox/dcim/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 64028dab3..95e98df2f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -360,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 From 2e8211399dd4f5ec38d031fc8833cece8fe05f57 Mon Sep 17 00:00:00 2001 From: bellwood Date: Wed, 20 Jul 2016 13:25:03 -0400 Subject: [PATCH 15/20] Update tables.py --- netbox/dcim/tables.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 65a1cbbb3..0fe2c6834 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -109,6 +109,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): From e1fc78bc44acca28894516e7b143bae07b81dfb6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Jul 2016 13:56:17 -0400 Subject: [PATCH 16/20] Created a template tag for displaying utilization graphs --- netbox/dcim/tables.py | 11 ++--------- netbox/ipam/tables.py | 11 ++--------- .../utilities/templatetags/utilization_graph.html | 7 +++++++ netbox/utilities/templatetags/helpers.py | 12 ++++++++++++ 4 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 netbox/templates/utilities/templatetags/utilization_graph.html diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 0fe2c6834..dfe2544fe 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -49,15 +49,8 @@ STATUS_ICON = """ """ UTILIZATION_GRAPH = """ -{% with record.get_utilization as percentage %} -
- {% if percentage < 15 %}{{ percentage }}%{% endif %} -
- {% if percentage >= 15 %}{{ percentage }}%{% endif %} -
-
-{% endwith %} +{% load helpers %} +{% utilization_graph record.get_utilization %} """ diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index f30906255..602462a2e 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -11,15 +11,8 @@ RIR_EDIT_LINK = """ """ UTILIZATION_GRAPH = """ -{% with record.get_utilization as percentage %} -
- {% if percentage < 15 %}{{ percentage }}%{% endif %} -
- {% if percentage >= 15 %}{{ percentage }}%{% endif %} -
-
-{% endwith %} +{% load helpers %} +{% utilization_graph record.get_utilization %} """ ROLE_EDIT_LINK = """ diff --git a/netbox/templates/utilities/templatetags/utilization_graph.html b/netbox/templates/utilities/templatetags/utilization_graph.html new file mode 100644 index 000000000..9232da3b8 --- /dev/null +++ b/netbox/templates/utilities/templatetags/utilization_graph.html @@ -0,0 +1,7 @@ +
+ {% if utilization < 30 %}{{ utilization }}%{% endif %} +
+ {% if utilization >= 30 %}{{ utilization }}%{% endif %} +
+
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index cc0b3b2e4..f26a06488 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/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, + } From 48b8602c3f861a2f4d143019acc3a57a7d52e6b9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Jul 2016 16:42:04 -0400 Subject: [PATCH 17/20] Corrected error reporting on duplicate InterfaceConnections --- netbox/dcim/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 95e98df2f..22ffcdf5b 100644 --- a/netbox/dcim/models.py +++ b/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 @@ -957,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): From 9a9e3c1479fbf063149c28b96b2aa926cc3bdb09 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Jul 2016 17:17:39 -0400 Subject: [PATCH 18/20] Upgrade to Django 1.9.8 (security fix) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7b87785df..67e806bf7 100644 --- a/requirements.txt +++ b/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 From 8ee083f7c118f70b6c5f4dcb21b40c23304a141f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Jul 2016 10:47:38 -0400 Subject: [PATCH 19/20] Fixed Unicode support in forms --- netbox/circuits/forms.py | 6 +++--- netbox/dcim/forms.py | 22 +++++++++++----------- netbox/ipam/forms.py | 18 +++++++++--------- netbox/secrets/forms.py | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 847402c7d..bfceb287a 100644 --- a/netbox/circuits/forms.py +++ b/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): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c8f7eca3f..43497b85c 100644 --- a/netbox/dcim/forms.py +++ b/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: @@ -548,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): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 5c0c7805f..f542f9bdd 100644 --- a/netbox/ipam/forms.py +++ b/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): @@ -540,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): diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index dfa3102f4..95f281502 100644 --- a/netbox/secrets/forms.py +++ b/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): From d2c3fea5b9348a31c018714274ebe837889cc9ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Jul 2016 11:45:59 -0400 Subject: [PATCH 20/20] Release v1.3.1 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 01f266194..7c916ed84 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.3.1-dev' +VERSION = '1.3.1' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: