From 079c8894facce7ee5a14dbe982bdfb8ed7de8f55 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 27 Feb 2018 14:59:45 -0500 Subject: [PATCH 01/16] Fixes #1915: Redirect to device view after deleting a component --- netbox/templates/dcim/inc/consoleport.html | 2 +- netbox/templates/dcim/inc/consoleserverport.html | 2 +- netbox/templates/dcim/inc/devicebay.html | 2 +- netbox/templates/dcim/inc/interface.html | 2 +- netbox/templates/dcim/inc/inventoryitem.html | 2 +- netbox/templates/dcim/inc/poweroutlet.html | 2 +- netbox/templates/dcim/inc/powerport.html | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 62375c7f2a2..4d75cc65b15 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -44,7 +44,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index aed27d62a85..673f51388da 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -49,7 +49,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index e6e4d3e47fa..4e17e3d36df 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -40,7 +40,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 783a564600a..aa0a9cbd5f7 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -124,7 +124,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index b5076527180..21de1014ead 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -11,7 +11,7 @@ {% endif %} {% if perms.dcim.delete_inventoryitem %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 30697720716..f3c855ea795 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -49,7 +49,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 555d6d3eefc..32e7f20fd4a 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -44,7 +44,7 @@ {% else %} - + {% endif %} From 1cc135f01f4ed27018d2980c7fb8b3460f3ac3aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 27 Feb 2018 15:40:24 -0500 Subject: [PATCH 02/16] Fixes #1919: Prevent exception when attempting to create a virtual machine without selecting devices --- netbox/dcim/views.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 02c87c122a9..8a8fb8d4c3c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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, From 36de9f10d63d42f4a06ad9e77d697e3cf7055852 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 27 Feb 2018 15:54:25 -0500 Subject: [PATCH 03/16] Closes #1918: Add note about copying media directory to upgrade doc --- docs/installation/upgrading.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 02dbb878f2b..02a08716b87 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -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 From 6881a980484ab53085f4deb0d0a0f00824a549a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 27 Feb 2018 16:10:02 -0500 Subject: [PATCH 04/16] Fixes #1924: Include VID in VLAN lists when editing an interface --- netbox/dcim/forms.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6d0892f6794..d74d6fcd9c7 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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' ) ) From e4c1cece75475052c97c7050c47997472a9262e2 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 27 Feb 2018 16:19:28 -0500 Subject: [PATCH 05/16] fixed #1921 - create interfaces with 801.1q in api --- netbox/dcim/api/serializers.py | 20 +++++++++-- netbox/dcim/tests/test_api.py | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index edcd93ef247..3f304556685 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -734,14 +734,30 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): # 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') - for vlan in data.get('tagged_vlans', []): + tagged_vlans = data.pop('tagged_vlans', []) + for vlan in 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) ) - return super(WritableInterfaceSerializer, self).validate(data) + validated_data = super(WritableInterfaceSerializer, self).validate(data) + if tagged_vlans: + validated_data['tagged_vlans'] = tagged_vlans + return validated_data + + def create(self, validated_data): + """ + Becasue tagged_vlans is a M2M relationship, we have to create the interface first + """ + tagged_vlans = validated_data.pop('tagged_vlans', None) + interface = Interface.objects.create(**validated_data) + interface.save() + if tagged_vlans: + interface.tagged_vlans = tagged_vlans + interface.save() + return interface # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5ad3985e1c9..ef17a878652 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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( From 9e11591b3b9f81dd8557c28002aa8b21e44ee985 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 27 Feb 2018 17:56:18 -0500 Subject: [PATCH 06/16] Post-release version bump (a bit late) --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 37a3585ec85..63f3a492dcb 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.0' +VERSION = '2.3.1-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 3cb351dceb1a34dbee125a33431c6effea5971aa Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 28 Feb 2018 16:31:53 -0500 Subject: [PATCH 07/16] fixed form bound check for site and vlan group --- netbox/dcim/forms.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6d0892f6794..da6ee428f62 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1728,10 +1728,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 +1854,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 = { @@ -1968,10 +1968,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 = { From 01a97add2a81c7358608ac83ad9bdc305ba69355 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 09:49:17 -0500 Subject: [PATCH 08/16] Fixes #1927: Include all VC member interaces on A side when creating a new interface connection --- netbox/dcim/forms.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d74d6fcd9c7..0c8ea371674 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2069,7 +2069,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 = [ @@ -2078,9 +2078,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): From 08d06bd78131e65bf21ff9d613a891262391f5c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 11:16:28 -0500 Subject: [PATCH 09/16] Fixes #1921: Ignore ManyToManyFields when validating a new object created via the API --- netbox/utilities/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 9dccdcc9db1..8471d0e00c7 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -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): + attrs.pop(field.name) instance = self.Meta.model(**attrs) else: instance = self.instance From b34f4f8e4318512139a6f985357ea8ce04b506e3 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Thu, 1 Mar 2018 11:31:56 -0500 Subject: [PATCH 10/16] refactor to handle M2M validation in ValidatedModelSerializer --- netbox/dcim/api/serializers.py | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3f304556685..17cf7fe17d7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -734,30 +734,13 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): # 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') - tagged_vlans = data.pop('tagged_vlans', []) - for vlan in tagged_vlans: + 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) ) - - validated_data = super(WritableInterfaceSerializer, self).validate(data) - if tagged_vlans: - validated_data['tagged_vlans'] = tagged_vlans - return validated_data - - def create(self, validated_data): - """ - Becasue tagged_vlans is a M2M relationship, we have to create the interface first - """ - tagged_vlans = validated_data.pop('tagged_vlans', None) - interface = Interface.objects.create(**validated_data) - interface.save() - if tagged_vlans: - interface.tagged_vlans = tagged_vlans - interface.save() - return interface + return data # From fc9871fba3af578197576a141a6830596fdc0d78 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 11:57:04 -0500 Subject: [PATCH 11/16] Fixes #1935: Correct API validation of VLANs assigned to interfaces --- netbox/dcim/api/serializers.py | 21 +++++++++++++-------- netbox/utilities/api.py | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index edcd93ef247..ea87c409803 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 8471d0e00c7..5c78dacc4c9 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -55,7 +55,7 @@ class ValidatedModelSerializer(ModelSerializer): 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): + if isinstance(field, ManyToManyField) and field.name in attrs: attrs.pop(field.name) instance = self.Meta.model(**attrs) else: From 4bb526896fd46c89007f87c6ce0309f60202669e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 12:37:12 -0500 Subject: [PATCH 12/16] Fixes #1934: Fixed exception when rendering export template on an object type with custom fields assigned --- netbox/utilities/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 9ed2b06d1e9..ff99a6401ba 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -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): From 078404fb5971d38d930f0fd2e3e70649bebcd19b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 13:10:36 -0500 Subject: [PATCH 13/16] Fixes #1926: Prevent reassignment of parent device when bulk editing VC member interfaces --- netbox/dcim/forms.py | 13 +------------ netbox/utilities/forms.py | 4 +++- netbox/utilities/views.py | 4 ++-- netbox/virtualization/forms.py | 1 - 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6645b198c32..593ffa6c0c9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1883,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') @@ -1943,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( diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 13e1e10f1fa..a2bfef001cb 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -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] diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ff99a6401ba..d060e53d7ce 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -507,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 [] @@ -565,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() diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 5a2f11763fa..06b9922035c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -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) From 6b62720dafdb440d908fcdb73409b44c8c8904f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 13:22:43 -0500 Subject: [PATCH 14/16] Closes #1910: Added filters for cluter group and cluster type --- netbox/virtualization/api/views.py | 2 ++ netbox/virtualization/filters.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index eadc93d5816..149bb3145b3 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -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): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 72faa909443..53c3f18d910 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -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( From bdecf7a3e31351c7a1ce0aad6c96fec42a8dbc70 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 14:40:39 -0500 Subject: [PATCH 15/16] Fixes #1936: Trigger validation error when attempting to create a virtual chassis without specifying member positions --- netbox/dcim/forms.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 593ffa6c0c9..e71f44389c8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2291,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): From 0c5ad85b357c90ed9d7177db1d7e2723b0c668f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 15:30:09 -0500 Subject: [PATCH 16/16] Release v2.3.1 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 63f3a492dcb..d79dc6ca5c7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.1-dev' +VERSION = '2.3.1' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))