Răsfoiți Sursa

Merge branch 'develop' into develop-2.4

Jeremy Stretch 7 ani în urmă
părinte
comite
33add12069

+ 11 - 5
docs/installation/upgrading.md

@@ -12,31 +12,37 @@ Download and extract the latest version:
 # wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
 # tar -xzf vX.Y.Z.tar.gz -C /opt
 # cd /opt/
-# ln -sf netbox-X.Y.Z/ netbox
+# ln -sfn netbox-X.Y.Z/ netbox
 ```
 
 Copy the 'configuration.py' you created when first installing to the new version:
 
 ```no-highlight
-# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
+# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
 ```
 
 Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
 
 ```no-highlight
-# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
+# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
+```
+
+Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location.
+
+```no-highlight
+# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/
 ```
 
 If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
 
 ```no-highlight
-# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
+# cp netbox-X.Y.Z/gunicorn_config.py netbox/gunicorn_config.py
 ```
 
 Copy the LDAP configuration if using LDAP:
 
 ```no-highlight
-# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
+# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
 ```
 
 ## Option B: Clone the Git Repository (latest master release)

+ 31 - 7
netbox/dcim/forms.py

@@ -169,13 +169,37 @@ class SiteCSVForm(forms.ModelForm):
 
 
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
-    status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
-    region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
-    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
-    asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
-    description = forms.CharField(max_length=100, required=False)
-    time_zone = TimeZoneFormField(required=False)
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(SITE_STATUS_CHOICES),
+        required=False,
+        initial=''
+    )
+    region = TreeNodeChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    tenant = forms.ModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    asn = forms.IntegerField(
+        min_value=1,
+        max_value=4294967295,
+        required=False,
+        label='ASN'
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    time_zone = TimeZoneFormField(
+        choices=add_blank_choice(TimeZoneFormField().choices),
+        required=False
+    )
 
     class Meta:
         nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']

+ 15 - 3
netbox/dcim/models.py

@@ -1586,7 +1586,7 @@ class ConsoleServerPort(models.Model):
             raise ValidationError("Console server ports must be assigned to devices.")
         device_type = self.device.device_type
         if not device_type.is_console_server:
-            raise ValidationError("The {} {} device type not support assignment of console server ports.".format(
+            raise ValidationError("The {} {} device type does not support assignment of console server ports.".format(
                 device_type.manufacturer, device_type
             ))
 
@@ -1688,7 +1688,7 @@ class PowerOutlet(models.Model):
             raise ValidationError("Power outlets must be assigned to devices.")
         device_type = self.device.device_type
         if not device_type.is_pdu:
-            raise ValidationError("The {} {} device type not support assignment of power outlets.".format(
+            raise ValidationError("The {} {} device type does not support assignment of power outlets.".format(
                 device_type.manufacturer, device_type
             ))
 
@@ -1794,7 +1794,7 @@ class Interface(models.Model):
         if self.device is not None:
             device_type = self.device.device_type
             if not device_type.is_network_device:
-                raise ValidationError("The {} {} device type not support assignment of network interfaces.".format(
+                raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format(
                     device_type.manufacturer, device_type
                 ))
 
@@ -1938,6 +1938,18 @@ class InterfaceConnection(models.Model):
                 raise ValidationError({
                     'interface_b': "Cannot connect an interface to itself."
                 })
+            if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
+                raise ValidationError({
+                    'interface_a': '{} is not a connectable interface type.'.format(
+                        self.interface_a.get_form_factor_display()
+                    )
+                })
+            if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
+                raise ValidationError({
+                    'interface_b': '{} is not a connectable interface type.'.format(
+                        self.interface_b.get_form_factor_display()
+                    )
+                })
         except ObjectDoesNotExist:
             pass
 

+ 6 - 1
netbox/dcim/signals.py

@@ -11,8 +11,13 @@ def assign_virtualchassis_master(instance, created, **kwargs):
     """
     When a VirtualChassis is created, automatically assign its master device to the VC.
     """
+    # Default to 1 but don't overwrite an existing position (see #2087)
+    if instance.master.vc_position is not None:
+        vc_position = instance.master.vc_position
+    else:
+        vc_position = 1
     if created:
-        Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
+        Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=vc_position)
 
 
 @receiver(pre_delete, sender=VirtualChassis)

+ 2 - 0
netbox/dcim/views.py

@@ -157,6 +157,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_region'
     cls = Region
     queryset = Region.objects.annotate(site_count=Count('sites'))
+    filter = filters.RegionFilter
     table = tables.RegionTable
     default_return_url = 'dcim:region_list'
 
@@ -491,6 +492,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     cls = RackReservation
+    filter = filters.RackReservationFilter
     table = tables.RackReservationTable
     default_return_url = 'dcim:rackreservation_list'
 

+ 4 - 2
netbox/ipam/fields.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 from django.core.exceptions import ValidationError
 from django.db import models
-from netaddr import IPNetwork
+from netaddr import AddrFormatError, IPNetwork
 
 from .formfields import IPFormField
 from . import lookups
@@ -26,7 +26,9 @@ class BaseIPField(models.Field):
             return value
         try:
             return IPNetwork(value)
-        except ValueError as e:
+        except AddrFormatError as e:
+            raise ValidationError("Invalid IP address format: {}".format(value))
+        except (TypeError, ValueError) as e:
             raise ValidationError(e)
 
     def get_prep_value(self, value):

+ 1 - 1
netbox/templates/dcim/device_lldp_neighbors.html

@@ -53,7 +53,7 @@ $(document).ready(function() {
         success: function(json) {
             $.each(json['get_lldp_neighbors'], function(iface, neighbors) {
                 var neighbor = neighbors[0];
-                var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
+                var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1"));
 
                 // Glean configured hostnames/interfaces from the DOM
                 var configured_device = row.children('td.configured_device').attr('data');

+ 1 - 1
netbox/templates/dcim/inc/interface.html

@@ -105,7 +105,7 @@
                     <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
                         <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
                     </button>
-                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
+                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                 {% else %}

+ 5 - 10
netbox/templates/dcim/rackrole_list.html

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

+ 5 - 2
netbox/utilities/views.py

@@ -637,8 +637,11 @@ class BulkDeleteView(View):
             return_url = reverse(self.default_return_url)
 
         # Are we deleting *all* objects in the queryset or just a selected subset?
-        if request.POST.get('_all') and self.filter is not None:
-            pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
+        if request.POST.get('_all'):
+            if self.filter is not None:
+                pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
+            else:
+                pk_list = self.cls.objects.values_list('pk', flat=True)
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 

+ 2 - 1
netbox/virtualization/views.py

@@ -115,7 +115,7 @@ class ClusterView(View):
             'site', 'rack', 'tenant', 'device_type__manufacturer'
         )
         device_table = DeviceTable(list(devices), orderable=False)
-        if request.user.has_perm('virtualization:change_cluster'):
+        if request.user.has_perm('virtualization.change_cluster'):
             device_table.columns.show('pk')
 
         return render(request, 'virtualization/cluster.html', {
@@ -161,6 +161,7 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'virtualization.delete_cluster'
     cls = Cluster
     queryset = Cluster.objects.all()
+    filter = filters.ClusterFilter
     table = tables.ClusterTable
     default_return_url = 'virtualization:cluster_list'