Skip to content

Release v2.6.9 #3774

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Dec 16, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/release-notes/version-2.6.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 19 additions & 1 deletion netbox/dcim/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 24 additions & 5 deletions netbox/extras/api/customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():

Expand Down Expand Up @@ -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:
Expand All @@ -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():
Expand Down
34 changes: 34 additions & 0 deletions netbox/extras/tests/test_customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 5 additions & 1 deletion netbox/ipam/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@
"""

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 = """
Expand Down
2 changes: 1 addition & 1 deletion netbox/netbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# Environment setup
#

VERSION = '2.6.8'
VERSION = '2.6.9'

# Hostname
HOSTNAME = platform.node()
Expand Down
34 changes: 17 additions & 17 deletions netbox/netbox/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
}),
))


Expand Down
41 changes: 22 additions & 19 deletions netbox/templates/dcim/site.html
Original file line number Diff line number Diff line change
Expand Up @@ -251,25 +251,28 @@ <h2><a href="{% url 'virtualization:virtualmachine_list' %}?site={{ site.slug }}
<div class="panel-heading">
<strong>Rack Groups</strong>
</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 class="panel panel-default">
<div class="panel-heading">
Expand Down
10 changes: 9 additions & 1 deletion netbox/templates/users/api_tokens.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
<div class="panel-heading">
<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 %}
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
{% endif %}
{% if perms.users.delete_token %}
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
{% endif %}
</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 %}
<span class="label label-danger">Expired</span>
{% endif %}
Expand Down Expand Up @@ -66,3 +68,9 @@
</div>
</div>
{% endblock %}

{% block javascript %}
<script type="text/javascript">
new ClipboardJS('.copy-token');
</script>
{% endblock %}
2 changes: 1 addition & 1 deletion netbox/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down