diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 02dbb878f..02a08716b 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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index edcd93ef2..d458bc646 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/dcim/forms.py b/netbox/dcim/forms.py index 6d0892f67..e71f44389 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' ) ) @@ -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): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5ad3985e1..ef17a8786 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( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 02c87c122..8a8fb8d4c 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, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 37a3585ec..d79dc6ca5 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' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 62375c7f2..4d75cc65b 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 aed27d62a..673f51388 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 e6e4d3e47..4e17e3d36 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 783a56460..aa0a9cbd5 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 b50765271..21de1014e 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 306977207..f3c855ea7 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 555d6d3ee..32e7f20fd 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -44,7 +44,7 @@ {% else %} - + {% endif %} diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 9dccdcc9d..5c78dacc4 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) and field.name in attrs: + attrs.pop(field.name) instance = self.Meta.model(**attrs) else: instance = self.instance diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 13e1e10f1..a2bfef001 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 9ed2b06d1..d060e53d7 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): @@ -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() diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index eadc93d58..149bb3145 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 72faa9094..53c3f18d9 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( diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 5a2f11763..06b992203 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)