diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md
index f499ec1cae..afad93fde3 100644
--- a/docs/release-notes/version-2.6.md
+++ b/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)
## Enhancements
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index ad2a3d769d..db88901b6b 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -9,7 +9,7 @@
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
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 mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
@@ -2730,6 +2730,24 @@ def clean(self):
'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):
return (
self.master,
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index 42dc486b86..2a13e5ce1b 100644
--- a/netbox/extras/api/customfields.py
+++ b/netbox/extras/api/customfields.py
@@ -22,7 +22,9 @@ def to_representation(self, obj):
def to_internal_value(self, data):
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():
@@ -107,11 +109,11 @@ def _populate_custom_fields(instance, fields):
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
try:
@@ -120,6 +122,23 @@ def _populate_custom_fields(instance, fields):
except TypeError:
_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):
content_type = ContentType.objects.get_for_model(self.Meta.model)
for field_name, value in custom_fields.items():
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index 96f3483bcb..7db4e26d92 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -301,6 +301,40 @@ def test_set_custom_field_select(self):
cfv = self.site.custom_field_values.get(field=self.cf_select)
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):
def setUp(self):
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
index 91f195ba04..e4d2bf8b4d 100644
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -85,7 +85,11 @@
"""
IPADDRESS_ASSIGN_LINK = """
-{{ record }}
+{% if request.GET %}
+ {{ record }}
+{% else %}
+ {{ record }}
+{% endif %}
"""
IPADDRESS_PARENT = """
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 9e17e4bb48..701af3c122 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -12,7 +12,7 @@
# Environment setup
#
-VERSION = '2.6.8'
+VERSION = '2.6.9'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py
index 05036a37a1..5dee6cadec 100644
--- a/netbox/netbox/views.py
+++ b/netbox/netbox/views.py
@@ -116,6 +116,23 @@
'table': PowerFeedTable,
'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
('vrf', {
'permission': 'ipam.view_vrf',
@@ -168,23 +185,6 @@
'table': TenantTable,
'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',
- }),
))
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index 0e38d29677..10e951efed 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -251,25 +251,28 @@
- {% for rg in rack_groups %}
-
- {{ rg }} |
- {{ rg.rack_count }} |
-
-
-
-
- |
-
- {% endfor %}
-
- {% else %}
-
- None
-
- {% endif %}
+
+ {% for rg in rack_groups %}
+
+ {{ rg }} |
+ {{ rg.rack_count }} |
+
+
+
+
+ |
+
+ {% endfor %}
+
+ All racks |
+ {{ stats.rack_count }} |
+
+
+
+
+ |
+
+
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html
index c6a2193813..b775af73e3 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/api_tokens.html
@@ -10,6 +10,7 @@
+
Copy
{% if perms.users.change_token %}
Edit
{% endif %}
@@ -17,7 +18,8 @@
Delete
{% endif %}
-
{{ token.key }}
+
+
{{ token.key }}
{% if token.is_expired %}
Expired
{% endif %}
@@ -66,3 +68,9 @@
{% endblock %}
+
+{% block javascript %}
+
+{% endblock %}
diff --git a/netbox/users/views.py b/netbox/users/views.py
index 47d3503d79..6a2410274a 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -96,7 +96,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
def get(self, request):
# 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.")
return redirect('user:profile')