Compare commits

...

21 Commits

Author SHA1 Message Date
Jeremy Stretch
ec4d28ac6c Merge pull request #1937 from digitalocean/develop
Release v2.3.1
2018-03-01 15:36:10 -05:00
Jeremy Stretch
0c5ad85b35 Release v2.3.1 2018-03-01 15:30:09 -05:00
Jeremy Stretch
bdecf7a3e3 Fixes #1936: Trigger validation error when attempting to create a virtual chassis without specifying member positions 2018-03-01 14:40:39 -05:00
Jeremy Stretch
6b62720daf Closes #1910: Added filters for cluter group and cluster type 2018-03-01 13:22:43 -05:00
Jeremy Stretch
d48c450018 Merge pull request #1925 from lampwins/bug/1921
fixed #1921 - create interfaces with 802.1q in api
2018-03-01 13:17:16 -05:00
Jeremy Stretch
078404fb59 Fixes #1926: Prevent reassignment of parent device when bulk editing VC member interfaces 2018-03-01 13:10:36 -05:00
Jeremy Stretch
4bb526896f Fixes #1934: Fixed exception when rendering export template on an object type with custom fields assigned 2018-03-01 12:37:12 -05:00
Jeremy Stretch
0476006ef2 Merge pull request #1929 from lampwins/bug/1928
Fixed #1928 form bound check for site and vlan group
2018-03-01 12:22:17 -05:00
John Anderson
19831f0177 Merge branch 'develop' into bug/1921 2018-03-01 12:11:46 -05:00
Jeremy Stretch
fc9871fba3 Fixes #1935: Correct API validation of VLANs assigned to interfaces 2018-03-01 12:05:25 -05:00
John Anderson
b34f4f8e43 refactor to handle M2M validation in ValidatedModelSerializer 2018-03-01 11:31:56 -05:00
John Anderson
0357d8522c Merge branch 'develop' into bug/1921 2018-03-01 11:26:52 -05:00
Jeremy Stretch
08d06bd781 Fixes #1921: Ignore ManyToManyFields when validating a new object created via the API 2018-03-01 11:16:28 -05:00
Jeremy Stretch
01a97add2a Fixes #1927: Include all VC member interaces on A side when creating a new interface connection 2018-03-01 09:49:17 -05:00
John Anderson
3cb351dceb fixed form bound check for site and vlan group 2018-02-28 16:31:53 -05:00
Jeremy Stretch
9e11591b3b Post-release version bump (a bit late) 2018-02-27 17:56:18 -05:00
John Anderson
e4c1cece75 fixed #1921 - create interfaces with 801.1q in api 2018-02-27 16:19:28 -05:00
Jeremy Stretch
6881a98048 Fixes #1924: Include VID in VLAN lists when editing an interface 2018-02-27 16:10:02 -05:00
Jeremy Stretch
36de9f10d6 Closes #1918: Add note about copying media directory to upgrade doc 2018-02-27 15:54:25 -05:00
Jeremy Stretch
1cc135f01f Fixes #1919: Prevent exception when attempting to create a virtual machine without selecting devices 2018-02-27 15:40:24 -05:00
Jeremy Stretch
079c8894fa Fixes #1915: Redirect to device view after deleting a component 2018-02-27 14:59:45 -05:00
19 changed files with 146 additions and 55 deletions

View File

@@ -21,6 +21,12 @@ Copy the 'configuration.py' you created when first installing to the new version
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/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/
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight

View File

@@ -731,15 +731,20 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
def validate(self, data):
# Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
# VirtualMachine, or are global.
parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
# All associated VLANs be global or assigned to the parent device's site.
device = self.instance.device if self.instance else data.get('device')
untagged_vlan = data.get('untagged_vlan')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [parent, None]:
raise serializers.ValidationError(
"Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
"global".format(vlan)
)
if vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
"be global.".format(vlan)
})
return super(WritableInterfaceSerializer, self).validate(data)

View File

@@ -1679,6 +1679,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
display_field='display_name'
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
@@ -1691,6 +1692,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
display_field='display_name'
)
)
@@ -1728,10 +1730,10 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
self.fields['site'].initial = None
# Limit the initial vlan choices
if self.is_bound:
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
}
elif self.initial.get('untagged_vlan'):
filter_dict = {
@@ -1854,10 +1856,10 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
self.fields['site'].initial = None
# Limit the initial vlan choices
if self.is_bound:
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
}
elif self.initial.get('untagged_vlan'):
filter_dict = {
@@ -1881,7 +1883,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
@@ -1941,17 +1942,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
device = None
if self.initial.get('device'):
try:
device = Device.objects.get(pk=self.initial.get('device'))
except Device.DoesNotExist:
pass
else:
try:
device = Device.objects.get(pk=self.data.get('device'))
except Device.DoesNotExist:
pass
device = self.parent_obj
if device is not None:
interface_ordering = device.device_type.interface_ordering
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
@@ -1968,10 +1959,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
if self.is_bound:
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
}
else:
filter_dict = {
@@ -2067,7 +2058,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
# Initialize interface A choices
device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related(
device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface_a'].choices = [
@@ -2076,9 +2067,11 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
# Mark connected interfaces as disabled
if self.data.get('device_b'):
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
]
self.fields['interface_b'].choices = []
for iface in self.fields['interface_b'].queryset:
self.fields['interface_b'].choices.append(
(iface.id, {'label': iface.name, 'disabled': iface.is_connected})
)
class InterfaceConnectionCSVForm(forms.ModelForm):
@@ -2298,11 +2291,12 @@ class BaseVCMemberFormSet(forms.BaseModelFormSet):
# Check for duplicate VC position values
vc_position_list = []
for form in self.forms:
vc_position = form.cleaned_data['vc_position']
if vc_position in vc_position_list:
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
form.add_error('vc_position', error_msg)
vc_position_list.append(vc_position)
vc_position = form.cleaned_data.get('vc_position')
if vc_position:
if vc_position in vc_position_list:
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
form.add_error('vc_position', error_msg)
vc_position_list.append(vc_position)
class DeviceVCMembershipForm(forms.ModelForm):

View File

@@ -12,6 +12,7 @@ from dcim.models import (
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis,
)
from ipam.models import VLAN
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from users.models import Token
from utilities.tests import HttpStatusMixin
@@ -2258,6 +2259,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3')
self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
def test_get_interface(self):
url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
@@ -2309,6 +2314,26 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.assertEqual(interface4.device_id, data['device'])
self.assertEqual(interface4.name, data['name'])
def test_create_interface_with_802_1q(self):
data = {
'device': self.device.pk,
'name': 'Test Interface 4',
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
'untagged_vlan': self.vlan3.id
}
url = reverse('dcim-api:interface-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 4)
interface5 = Interface.objects.get(pk=response.data['id'])
self.assertEqual(interface5.device_id, data['device'])
self.assertEqual(interface5.name, data['name'])
self.assertEqual(interface5.tagged_vlans.count(), 2)
self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan'])
def test_create_interface_bulk(self):
data = [
@@ -2335,6 +2360,44 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_create_interface_802_1q_bulk(self):
data = [
{
'device': self.device.pk,
'name': 'Test Interface 4',
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
{
'device': self.device.pk,
'name': 'Test Interface 5',
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
{
'device': self.device.pk,
'name': 'Test Interface 6',
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
]
url = reverse('dcim-api:interface-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
self.assertEqual(len(response.data[0]['tagged_vlans']), 1)
self.assertEqual(len(response.data[1]['tagged_vlans']), 1)
self.assertEqual(len(response.data[2]['tagged_vlans']), 1)
self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id)
self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id)
self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id)
def test_update_interface(self):
lag_interface = Interface.objects.create(

View File

@@ -7,7 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import Count, Q
from django.forms import ModelChoiceField, ModelForm, modelformset_factory
from django.forms import modelformset_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -2082,14 +2082,13 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
# Get the list of devices being added to a VirtualChassis
pk_form = forms.DeviceSelectionForm(request.POST)
pk_form.full_clean()
if not pk_form.cleaned_data.get('pk'):
messages.warning(request, "No devices were selected.")
return redirect('dcim:device_list')
device_queryset = Device.objects.filter(
pk__in=pk_form.cleaned_data.get('pk')
).select_related('rack').order_by('vc_position')
if not device_queryset:
messages.warning(request, "No devices were selected.")
return redirect('dcim:device_list')
VCMemberFormSet = modelformset_factory(
model=Device,
formset=forms.BaseVCMemberFormSet,

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.3.0'
VERSION = '2.3.1'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@@ -44,7 +44,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -40,7 +40,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
</a>
{% endif %}

View File

@@ -124,7 +124,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}" class="btn btn-danger btn-xs" title="Delete interface">
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -11,7 +11,7 @@
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>

View File

@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}" title="Delete outlet" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Delete outlet" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -44,7 +44,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -5,6 +5,7 @@ import pytz
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import ManyToManyField
from django.http import Http404
from rest_framework import mixins
from rest_framework.exceptions import APIException
@@ -51,6 +52,11 @@ class ValidatedModelSerializer(ModelSerializer):
# Run clean() on an instance of the model
if self.instance is None:
model = self.Meta.model
# Ignore ManyToManyFields for new instances (a PK is needed for validation)
for field in model._meta.get_fields():
if isinstance(field, ManyToManyField) and field.name in attrs:
attrs.pop(field.name)
instance = self.Meta.model(**attrs)
else:
instance = self.instance

View File

@@ -539,9 +539,11 @@ class ComponentForm(BootstrapMixin, forms.Form):
class BulkEditForm(forms.Form):
def __init__(self, model, *args, **kwargs):
def __init__(self, model, parent_obj=None, *args, **kwargs):
super(BulkEditForm, self).__init__(*args, **kwargs)
self.model = model
self.parent_obj = parent_obj
# Copy any nullable fields defined in Meta
if hasattr(self.Meta, 'nullable_fields'):
self.nullable_fields = [field for field in self.Meta.nullable_fields]

View File

@@ -35,6 +35,7 @@ class CustomFieldQueryset:
def __init__(self, queryset, custom_fields):
self.queryset = queryset
self.model = queryset.model
self.custom_fields = custom_fields
def __iter__(self):
@@ -506,7 +507,7 @@ class BulkEditView(View):
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST:
form = self.form(self.cls, request.POST)
form = self.form(self.cls, parent_obj, request.POST)
if form.is_valid():
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
@@ -564,7 +565,7 @@ class BulkEditView(View):
else:
initial_data = request.POST.copy()
initial_data['pk'] = pk_list
form = self.form(self.cls, initial=initial_data)
form = self.form(self.cls, parent_obj, initial=initial_data)
# Retrieve objects being edited
queryset = self.queryset or self.cls.objects.all()

View File

@@ -25,11 +25,13 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet):
class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.all()
serializer_class = serializers.ClusterTypeSerializer
filter_class = filters.ClusterTypeFilter
class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.all()
serializer_class = serializers.ClusterGroupSerializer
filter_class = filters.ClusterGroupFilter
class ClusterViewSet(CustomFieldModelViewSet):

View File

@@ -13,6 +13,20 @@ from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class ClusterTypeFilter(django_filters.FilterSet):
class Meta:
model = ClusterType
fields = ['name', 'slug']
class ClusterGroupFilter(django_filters.FilterSet):
class Meta:
model = ClusterGroup
fields = ['name', 'slug']
class ClusterFilter(CustomFieldFilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(

View File

@@ -442,7 +442,6 @@ class InterfaceCreateForm(ComponentForm):
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
description = forms.CharField(max_length=100, required=False)