Jelajahi Sumber

Merge pull request #3774 from netbox-community/develop

Release v2.6.9
Jeremy Stretch 6 tahun lalu
induk
melakukan
50df3acd26

+ 15 - 0
docs/release-notes/version-2.6.md

@@ -1,3 +1,18 @@
+# v2.6.9 (2019-12-16)
+
+## Enhancements
+
+* [#3152](https://github.com/netbox-community/netbox/issues/3152) - Include direct link to rack elevations on site view
+* [#3441](https://github.com/netbox-community/netbox/issues/3441) - Move virtual machine results near devices in global search
+* [#3761](https://github.com/netbox-community/netbox/issues/3761) - Added copy button for API tokens
+
+## Bug Fixes
+
+* [#2170](https://github.com/netbox-community/netbox/issues/2170) - Prevent the deletion of a virtual chassis when a cross-member LAG is present
+* [#2358](https://github.com/netbox-community/netbox/issues/2358) - Respect custom field default values when creating objects via the REST API
+* [#3749](https://github.com/netbox-community/netbox/issues/3749) - Fix exception on password change page for local users
+* [#3757](https://github.com/netbox-community/netbox/issues/3757) - Fix unable to assign IP to interface
+
 # v2.6.8 (2019-12-10)
 # v2.6.8 (2019-12-10)
 
 
 ## Enhancements
 ## Enhancements

+ 19 - 1
netbox/dcim/models.py

@@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
-from django.db.models import Count, Q, Sum
+from django.db.models import Count, F, ProtectedError, Q, Sum
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
@@ -2730,6 +2730,24 @@ class VirtualChassis(ChangeLoggedModel):
                 'master': "The selected master is not assigned to this virtual chassis."
                 'master': "The selected master is not assigned to this virtual chassis."
             })
             })
 
 
+    def delete(self, *args, **kwargs):
+
+        # Check for LAG interfaces split across member chassis
+        interfaces = Interface.objects.filter(
+            device__in=self.members.all(),
+            lag__isnull=False
+        ).exclude(
+            lag__device=F('device')
+        )
+        if interfaces:
+            raise ProtectedError(
+                "Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
+                "LAG".format(self),
+                interfaces
+            )
+
+        return super().delete(*args, **kwargs)
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.master,
             self.master,

+ 24 - 5
netbox/extras/api/customfields.py

@@ -22,7 +22,9 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
     def to_internal_value(self, data):
     def to_internal_value(self, data):
 
 
         content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
         content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
-        custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
+        custom_fields = {
+            field.name: field for field in CustomField.objects.filter(obj_type=content_type)
+        }
 
 
         for field_name, value in data.items():
         for field_name, value in data.items():
 
 
@@ -107,11 +109,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        if self.instance is not None:
+        # Retrieve the set of CustomFields which apply to this type of object
+        content_type = ContentType.objects.get_for_model(self.Meta.model)
+        fields = CustomField.objects.filter(obj_type=content_type)
 
 
-            # Retrieve the set of CustomFields which apply to this type of object
-            content_type = ContentType.objects.get_for_model(self.Meta.model)
-            fields = CustomField.objects.filter(obj_type=content_type)
+        if self.instance is not None:
 
 
             # Populate CustomFieldValues for each instance from database
             # Populate CustomFieldValues for each instance from database
             try:
             try:
@@ -120,6 +122,23 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
             except TypeError:
             except TypeError:
                 _populate_custom_fields(self.instance, fields)
                 _populate_custom_fields(self.instance, fields)
 
 
+        else:
+
+            # Populate default values
+            if fields and 'custom_fields' not in self.initial_data:
+                self.initial_data['custom_fields'] = {}
+
+            # Populate initial data using custom field default values
+            for field in fields:
+                if field.name not in self.initial_data['custom_fields'] and field.default:
+                    if field.type == CF_TYPE_SELECT:
+                        field_value = field.choices.get(value=field.default).pk
+                    elif field.type == CF_TYPE_BOOLEAN:
+                        field_value = bool(field.default)
+                    else:
+                        field_value = field.default
+                    self.initial_data['custom_fields'][field.name] = field_value
+
     def _save_custom_fields(self, instance, custom_fields):
     def _save_custom_fields(self, instance, custom_fields):
         content_type = ContentType.objects.get_for_model(self.Meta.model)
         content_type = ContentType.objects.get_for_model(self.Meta.model)
         for field_name, value in custom_fields.items():
         for field_name, value in custom_fields.items():

+ 34 - 0
netbox/extras/tests/test_customfields.py

@@ -301,6 +301,40 @@ class CustomFieldAPITest(APITestCase):
         cfv = self.site.custom_field_values.get(field=self.cf_select)
         cfv = self.site.custom_field_values.get(field=self.cf_select)
         self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
         self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
 
 
+    def test_set_custom_field_defaults(self):
+        """
+        Create a new object with no custom field data. Custom field values should be created using the custom fields'
+        default values.
+        """
+        CUSTOM_FIELD_DEFAULTS = {
+            'magic_word': 'foobar',
+            'magic_number': '123',
+            'is_magic': 'true',
+            'magic_date': '2019-12-13',
+            'magic_url': 'http://example.com/',
+            'magic_choice': self.cf_select_choice1.value,
+        }
+
+        # Update CustomFields to set default values
+        for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
+            CustomField.objects.filter(name=field_name).update(default=default_value)
+
+        data = {
+            'name': 'Test Site X',
+            'slug': 'test-site-x',
+        }
+
+        url = reverse('dcim-api:site-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
+        self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
+        self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
+        self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
+        self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
+        self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
+
 
 
 class CustomFieldChoiceAPITest(APITestCase):
 class CustomFieldChoiceAPITest(APITestCase):
     def setUp(self):
     def setUp(self):

+ 5 - 1
netbox/ipam/tables.py

@@ -85,7 +85,11 @@ IPADDRESS_LINK = """
 """
 """
 
 
 IPADDRESS_ASSIGN_LINK = """
 IPADDRESS_ASSIGN_LINK = """
-<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
+{% if request.GET %}
+    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
+{% else %}
+    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
+{% endif %}
 """
 """
 
 
 IPADDRESS_PARENT = """
 IPADDRESS_PARENT = """

+ 1 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.6.8'
+VERSION = '2.6.9'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 17 - 17
netbox/netbox/views.py

@@ -116,6 +116,23 @@ SEARCH_TYPES = OrderedDict((
         'table': PowerFeedTable,
         'table': PowerFeedTable,
         'url': 'dcim:powerfeed_list',
         'url': 'dcim:powerfeed_list',
     }),
     }),
+    # Virtualization
+    ('cluster', {
+        'permission': 'virtualization.view_cluster',
+        'queryset': Cluster.objects.prefetch_related('type', 'group'),
+        'filter': ClusterFilter,
+        'table': ClusterTable,
+        'url': 'virtualization:cluster_list',
+    }),
+    ('virtualmachine', {
+        'permission': 'virtualization.view_virtualmachine',
+        'queryset': VirtualMachine.objects.prefetch_related(
+            'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+        ),
+        'filter': VirtualMachineFilter,
+        'table': VirtualMachineDetailTable,
+        'url': 'virtualization:virtualmachine_list',
+    }),
     # IPAM
     # IPAM
     ('vrf', {
     ('vrf', {
         'permission': 'ipam.view_vrf',
         'permission': 'ipam.view_vrf',
@@ -168,23 +185,6 @@ SEARCH_TYPES = OrderedDict((
         'table': TenantTable,
         'table': TenantTable,
         'url': 'tenancy:tenant_list',
         'url': 'tenancy:tenant_list',
     }),
     }),
-    # Virtualization
-    ('cluster', {
-        'permission': 'virtualization.view_cluster',
-        'queryset': Cluster.objects.prefetch_related('type', 'group'),
-        'filter': ClusterFilter,
-        'table': ClusterTable,
-        'url': 'virtualization:cluster_list',
-    }),
-    ('virtualmachine', {
-        'permission': 'virtualization.view_virtualmachine',
-        'queryset': VirtualMachine.objects.prefetch_related(
-            'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
-        ),
-        'filter': VirtualMachineFilter,
-        'table': VirtualMachineDetailTable,
-        'url': 'virtualization:virtualmachine_list',
-    }),
 ))
 ))
 
 
 
 

+ 22 - 19
netbox/templates/dcim/site.html

@@ -251,25 +251,28 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Rack Groups</strong>
                 <strong>Rack Groups</strong>
             </div>
             </div>
-            {% if rack_groups %}
-                <table class="table table-hover panel-body">
-                    {% for rg in rack_groups %}
-                        <tr>
-                            <td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
-                            <td>{{ rg.rack_count }}</td>
-                            <td class="text-right noprint">
-                                <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
-                                    <i class="fa fa-eye"></i>
-                                </a>
-                            </td>
-                        </tr>
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="panel-body text-muted">
-                    None
-                </div>
-            {% endif %}
+            <table class="table table-hover panel-body">
+                {% for rg in rack_groups %}
+                    <tr>
+                        <td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
+                        <td>{{ rg.rack_count }}</td>
+                        <td class="text-right noprint">
+                            <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
+                                <i class="fa fa-eye"></i>
+                            </a>
+                        </td>
+                    </tr>
+                {% endfor %}
+                <tr>
+                    <td><i class="fa fa-fw fa-folder-o"></i> All racks</td>
+                    <td>{{ stats.rack_count }}</td>
+                    <td class="text-right noprint">
+                        <a href="{% url 'dcim:rack_elevation_list' %}?site={{ site.slug }}" class="btn btn-xs btn-primary" title="View elevations">
+                            <i class="fa fa-eye"></i>
+                        </a>
+                    </td>
+                </tr>
+            </table>
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">

+ 9 - 1
netbox/templates/users/api_tokens.html

@@ -10,6 +10,7 @@
                 <div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
                 <div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
                     <div class="panel-heading">
                     <div class="panel-heading">
                         <div class="pull-right noprint">
                         <div class="pull-right noprint">
+                            <a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
                             {% if perms.users.change_token %}
                             {% if perms.users.change_token %}
                                 <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
                                 <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
                             {% endif %}
                             {% endif %}
@@ -17,7 +18,8 @@
                                 <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
                                 <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
                             {% endif %}
                             {% endif %}
                         </div>
                         </div>
-                        <i class="fa fa-key"></i> {{ token.key }}
+                        <i class="fa fa-key"></i>
+                        <span id="token_{{ token.pk }}">{{ token.key }}</span>
                         {% if token.is_expired %}
                         {% if token.is_expired %}
                             <span class="label label-danger">Expired</span>
                             <span class="label label-danger">Expired</span>
                         {% endif %}
                         {% endif %}
@@ -66,3 +68,9 @@
         </div>
         </div>
     </div>
     </div>
 {% endblock %}
 {% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+new ClipboardJS('.copy-token');
+</script>
+{% endblock %}

+ 1 - 1
netbox/users/views.py

@@ -96,7 +96,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
 
 
     def get(self, request):
     def get(self, request):
         # LDAP users cannot change their password here
         # LDAP users cannot change their password here
-        if getattr(request.user, 'ldap_username'):
+        if getattr(request.user, 'ldap_username', None):
             messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
             messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
             return redirect('user:profile')
             return redirect('user:profile')