Skip to content
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

Release v2.3.1 #1937

Merged
merged 20 commits into from
Mar 1, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
079c889
Fixes #1915: Redirect to device view after deleting a component
jeremystretch Feb 27, 2018
1cc135f
Fixes #1919: Prevent exception when attempting to create a virtual ma…
jeremystretch Feb 27, 2018
36de9f1
Closes #1918: Add note about copying media directory to upgrade doc
jeremystretch Feb 27, 2018
6881a98
Fixes #1924: Include VID in VLAN lists when editing an interface
jeremystretch Feb 27, 2018
e4c1cec
fixed #1921 - create interfaces with 801.1q in api
lampwins Feb 27, 2018
9e11591
Post-release version bump (a bit late)
jeremystretch Feb 27, 2018
3cb351d
fixed form bound check for site and vlan group
lampwins Feb 28, 2018
01a97ad
Fixes #1927: Include all VC member interaces on A side when creating …
jeremystretch Mar 1, 2018
08d06bd
Fixes #1921: Ignore ManyToManyFields when validating a new object cre…
jeremystretch Mar 1, 2018
0357d85
Merge branch 'develop' into bug/1921
lampwins Mar 1, 2018
b34f4f8
refactor to handle M2M validation in ValidatedModelSerializer
lampwins Mar 1, 2018
fc9871f
Fixes #1935: Correct API validation of VLANs assigned to interfaces
jeremystretch Mar 1, 2018
19831f0
Merge branch 'develop' into bug/1921
lampwins Mar 1, 2018
0476006
Merge pull request #1929 from lampwins/bug/1928
jeremystretch Mar 1, 2018
4bb5268
Fixes #1934: Fixed exception when rendering export template on an obj…
jeremystretch Mar 1, 2018
078404f
Fixes #1926: Prevent reassignment of parent device when bulk editing …
jeremystretch Mar 1, 2018
d48c450
Merge pull request #1925 from lampwins/bug/1921
jeremystretch Mar 1, 2018
6b62720
Closes #1910: Added filters for cluter group and cluster type
jeremystretch Mar 1, 2018
bdecf7a
Fixes #1936: Trigger validation error when attempting to create a vir…
jeremystretch Mar 1, 2018
0c5ad85
Release v2.3.1
jeremystretch Mar 1, 2018
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
6 changes: 6 additions & 0 deletions docs/installation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions netbox/dcim/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,15 +731,20 @@ class Meta:

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)

Expand Down
54 changes: 24 additions & 30 deletions netbox/dcim/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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'
)
)

Expand Down Expand Up @@ -1728,10 +1730,10 @@ def __init__(self, *args, **kwargs):
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 = {
Expand Down Expand Up @@ -1854,10 +1856,10 @@ def __init__(self, *args, **kwargs):
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 = {
Expand All @@ -1881,7 +1883,6 @@ def __init__(self, *args, **kwargs):

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')
Expand Down Expand Up @@ -1941,17 +1942,7 @@ def __init__(self, *args, **kwargs):
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(
Expand All @@ -1968,10 +1959,10 @@ def __init__(self, *args, **kwargs):
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 = {
Expand Down Expand Up @@ -2067,7 +2058,7 @@ def __init__(self, device_a, *args, **kwargs):
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 = [
Expand All @@ -2076,9 +2067,11 @@ def __init__(self, device_a, *args, **kwargs):

# 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):
Expand Down Expand Up @@ -2298,11 +2291,12 @@ def clean(self):
# 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):
Expand Down
63 changes: 63 additions & 0 deletions netbox/dcim/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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
Expand Down Expand Up @@ -2258,6 +2259,10 @@ def setUp(self):
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})
Expand Down Expand Up @@ -2309,6 +2314,26 @@ def test_create_interface(self):
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 = [
Expand All @@ -2335,6 +2360,44 @@ def test_create_interface_bulk(self):
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(
Expand Down
9 changes: 4 additions & 5 deletions netbox/dcim/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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
Expand Down Expand Up @@ -2082,14 +2082,13 @@ def post(self, request):
# 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,
Expand Down
2 changes: 1 addition & 1 deletion netbox/netbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
DeprecationWarning
)

VERSION = '2.3.0'
VERSION = '2.3.1'

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/consoleport.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/consoleserverport.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/devicebay.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/inventoryitem.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/poweroutlet.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
2 changes: 1 addition & 1 deletion netbox/templates/dcim/inc/powerport.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
6 changes: 6 additions & 0 deletions netbox/utilities/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

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
Expand Down Expand Up @@ -51,6 +52,11 @@ def validate(self, data):

# 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
Expand Down
4 changes: 3 additions & 1 deletion netbox/utilities/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,11 @@ def __init__(self, parent, *args, **kwargs):

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]
Expand Down
Loading