diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 83fcd7a2a..5d9380c00 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -332,7 +332,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): class Meta: model = models.VirtualChassis - fields = ['id', 'url', 'master', 'member_count'] + fields = ['id', 'name', 'url', 'master', 'member_count'] # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c684b8041..45a908685 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -694,12 +694,12 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer): - master = NestedDeviceSerializer() + master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'master', 'domain', 'tags', 'member_count'] + fields = ['id', 'name', 'domain', 'master', 'tags', 'member_count'] # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 099676181..6f51160fa 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4114,7 +4114,38 @@ class DeviceSelectionForm(forms.Form): ) -class VirtualChassisForm(BootstrapMixin, forms.ModelForm): +class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'rack': 'site_id', + 'members': 'site_id', + } + ) + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'members': 'rack_id' + }, + attrs={ + 'nullable': 'true', + } + ) + ) + members = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + ) + initial_position = forms.IntegerField( + initial=1, + required=False, + help_text='Position of the first member device. Increases by one for each additional member.' + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -4123,12 +4154,47 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class Meta: model = VirtualChassis fields = [ - 'master', 'domain', 'tags', + 'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags', + ] + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Assign VC members + if instance.pk: + initial_position = self.cleaned_data.get('initial_position') or 1 + for i, member in enumerate(self.cleaned_data['members'], start=initial_position): + member.virtual_chassis = instance + member.vc_position = i + member.save() + + return instance + + +class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VirtualChassis + fields = [ + 'name', 'domain', 'master', 'tags', ] widgets = { 'master': SelectWithPK(), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance) + class BaseVCMemberFormSet(forms.BaseModelFormSet): @@ -4221,7 +4287,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): device = self.cleaned_data['device'] if device.virtual_chassis is not None: raise forms.ValidationError( - "Device {} is already assigned to a virtual chassis.".format(device) + f"Device {device} is already assigned to a virtual chassis." ) return device diff --git a/netbox/dcim/migrations/0110_virtualchassis_name.py b/netbox/dcim/migrations/0110_virtualchassis_name.py new file mode 100644 index 000000000..e8455d6fe --- /dev/null +++ b/netbox/dcim/migrations/0110_virtualchassis_name.py @@ -0,0 +1,46 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def copy_master_name(apps, schema_editor): + """ + Copy the master device's name to the VirtualChassis. + """ + VirtualChassis = apps.get_model('dcim', 'VirtualChassis') + + for vc in VirtualChassis.objects.prefetch_related('master'): + name = vc.master.name if vc.master.name else f'Unnamed VC #{vc.pk}' + VirtualChassis.objects.filter(pk=vc.pk).update(name=name) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0109_interface_remove_vm'), + ] + + operations = [ + migrations.AlterModelOptions( + name='virtualchassis', + options={'ordering': ['name'], 'verbose_name_plural': 'virtual chassis'}, + ), + migrations.AddField( + model_name='virtualchassis', + name='name', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AlterField( + model_name='virtualchassis', + name='master', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'), + ), + migrations.RunPython( + code=copy_master_name, + reverse_code=migrations.RunPython.noop + ), + migrations.AlterField( + model_name='virtualchassis', + name='name', + field=models.CharField(max_length=64), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d8a5f028c..f930ae02d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1572,9 +1572,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): raise ValidationError({ 'primary_ip4': f"{self.primary_ip4} is not an IPv4 address." }) - if self.primary_ip4.interface in vc_interfaces: + if self.primary_ip4.assigned_object in vc_interfaces: pass - elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces: + elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces: pass else: raise ValidationError({ @@ -1585,9 +1585,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): raise ValidationError({ 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address." }) - if self.primary_ip6.interface in vc_interfaces: + if self.primary_ip6.assigned_object in vc_interfaces: pass - elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces: + elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces: pass else: raise ValidationError({ @@ -1757,7 +1757,12 @@ class VirtualChassis(ChangeLoggedModel): master = models.OneToOneField( to='Device', on_delete=models.PROTECT, - related_name='vc_master_for' + related_name='vc_master_for', + blank=True, + null=True + ) + name = models.CharField( + max_length=64 ) domain = models.CharField( max_length=30, @@ -1767,14 +1772,14 @@ class VirtualChassis(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['master', 'domain'] + csv_headers = ['name', 'domain', 'master'] class Meta: - ordering = ['master'] + ordering = ['name'] verbose_name_plural = 'virtual chassis' def __str__(self): - return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis' + return self.name def get_absolute_url(self): return reverse('dcim:virtualchassis', kwargs={'pk': self.pk}) @@ -1783,9 +1788,9 @@ class VirtualChassis(ChangeLoggedModel): # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new # VirtualChassis.) - if self.pk and self.master not in self.members.all(): + if self.pk and self.master and self.master not in self.members.all(): raise ValidationError({ - 'master': "The selected master is not assigned to this virtual chassis." + 'master': f"The selected master ({self.master}) is not assigned to this virtual chassis." }) def delete(self, *args, **kwargs): @@ -1799,8 +1804,7 @@ class VirtualChassis(ChangeLoggedModel): ) if interfaces: raise ProtectedError( - "Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis " - "LAG".format(self), + f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG", interfaces ) @@ -1808,8 +1812,9 @@ class VirtualChassis(ChangeLoggedModel): def to_csv(self): return ( - self.master, + self.name, self.domain, + self.master.name if self.master else None, ) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index c94ecf61e..556cde6a5 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -10,14 +10,12 @@ from .models import Cable, Device, VirtualChassis @receiver(post_save, sender=VirtualChassis) def assign_virtualchassis_master(instance, created, **kwargs): """ - When a VirtualChassis is created, automatically assign its master device to the VC. + When a VirtualChassis is created, automatically assign its master device (if any) to the VC. """ - if created: - devices = Device.objects.filter(pk=instance.master.pk) - for device in devices: - device.virtual_chassis = instance - device.vc_position = None - device.save() + if created and instance.master: + instance.master.virtual_chassis = instance + instance.master.vc_position = 1 + instance.master.save() @receiver(pre_delete, sender=VirtualChassis) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 82b2414ad..6aa41ab44 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -1167,7 +1167,9 @@ class InventoryItemTable(BaseTable): class VirtualChassisTable(BaseTable): pk = ToggleColumn() name = tables.Column( - accessor=Accessor('master__name'), + linkify=True + ) + master = tables.Column( linkify=True ) member_count = tables.Column( @@ -1179,8 +1181,8 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis - fields = ('pk', 'name', 'domain', 'member_count', 'tags') - default_columns = ('pk', 'name', 'domain', 'member_count') + fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags') + default_columns = ('pk', 'name', 'domain', 'master', 'member_count') # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index cb0eed994..052a77e53 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2003,7 +2003,7 @@ class ConnectedDeviceTest(APITestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis - brief_fields = ['id', 'master', 'member_count', 'url'] + brief_fields = ['id', 'master', 'member_count', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -2040,9 +2040,9 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): # Create three VirtualChassis with three members each virtual_chassis = ( - VirtualChassis(master=devices[0], domain='domain-1'), - VirtualChassis(master=devices[3], domain='domain-2'), - VirtualChassis(master=devices[6], domain='domain-3'), + VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'), + VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'), + VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'), ) VirtualChassis.objects.bulk_create(virtual_chassis) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) @@ -2053,21 +2053,22 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) cls.update_data = { - 'master': devices[1].pk, + 'name': 'Virtual Chassis X', 'domain': 'domain-x', + 'master': devices[1].pk, } cls.create_data = [ { - 'master': devices[9].pk, + 'name': 'Virtual Chassis 4', 'domain': 'domain-4', }, { - 'master': devices[10].pk, + 'name': 'Virtual Chassis 5', 'domain': 'domain-5', }, { - 'master': devices[11].pk, + 'name': 'Virtual Chassis 6', 'domain': 'domain-6', }, ] diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b9a02b318..23e65eb05 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1563,16 +1563,7 @@ class CableTestCase( } -# TODO: Change base class to PrimaryObjectViewTestCase -# Blocked by standard creation, bulk creation views for VirtualChassis (member devices must be selected in bulk) -class VirtualChassisTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis @classmethod @@ -1602,19 +1593,22 @@ class VirtualChassisTestCase( Device.objects.bulk_create(devices) # Create three VirtualChassis with two members each - vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1') + vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1') + Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2) Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3) - vc2 = VirtualChassis.objects.create(master=devices[3], domain='domain-2') + vc2 = VirtualChassis.objects.create(name='VC2', master=devices[3], domain='domain-2') + Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=vc2, vc_position=1) Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2) Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3) - vc3 = VirtualChassis.objects.create(master=devices[6], domain='domain-3') + vc3 = VirtualChassis.objects.create(name='VC3', master=devices[6], domain='domain-3') + Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=vc3, vc_position=1) Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2) Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3) cls.form_data = { - 'master': devices[1].pk, - 'domain': 'domain-x', + 'name': 'VC4', + 'domain': 'domain-4', # Management form data for VC members 'form-TOTAL_FORMS': 0, 'form-INITIAL_FORMS': 3, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ba32a7643..f6fe7cf74 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2117,62 +2117,11 @@ class VirtualChassisView(ObjectView): }) -class VirtualChassisCreateView(ObjectPermissionRequiredMixin, View): +class VirtualChassisCreateView(ObjectEditView): queryset = VirtualChassis.objects.all() - - def get_required_permission(self): - return 'dcim.add_virtualchassis' - - 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') - ).prefetch_related('rack').order_by('vc_position') - - VCMemberFormSet = modelformset_factory( - model=Device, - formset=forms.BaseVCMemberFormSet, - form=forms.DeviceVCMembershipForm, - extra=0 - ) - - if '_create' in request.POST: - - vc_form = forms.VirtualChassisForm(request.POST) - vc_form.fields['master'].queryset = device_queryset - formset = VCMemberFormSet(request.POST, queryset=device_queryset) - - if vc_form.is_valid() and formset.is_valid(): - - with transaction.atomic(): - - # Assign each device to the VirtualChassis before saving - virtual_chassis = vc_form.save() - devices = formset.save(commit=False) - for device in devices: - device.virtual_chassis = virtual_chassis - device.save() - - return redirect(vc_form.cleaned_data['master'].get_absolute_url()) - - else: - - vc_form = forms.VirtualChassisForm() - vc_form.fields['master'].queryset = device_queryset - formset = VCMemberFormSet(queryset=device_queryset) - - return render(request, 'dcim/virtualchassis_edit.html', { - 'pk_form': pk_form, - 'vc_form': vc_form, - 'formset': formset, - 'return_url': reverse('dcim:device_list'), - }) + model_form = forms.VirtualChassisCreateForm + template_name = 'dcim/virtualchassis_add.html' + default_return_url = 'dcim:virtualchassis_list' class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): @@ -2234,7 +2183,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V for member in members: member.save() - return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + return redirect(virtual_chassis.get_absolute_url()) return render(request, 'dcim/virtualchassis_edit.html', { 'vc_form': vc_form, diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index ebee21d18..f236a0550 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -17,9 +17,4 @@ {% endif %} - {% if perms.dcim.add_virtualchassis %} - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index a97c42e4f..c1ad82c5d 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -9,7 +9,9 @@
@@ -63,7 +65,17 @@ Domain {{ virtualchassis.domain|placeholder }} - + + + Master + + {% if virtualchassis.master %} + {{ virtualchassis.master }} + {% else %} + + {% endif %} + + {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..07b17f378 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,22 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Virtual Chassis
+
+ {% render_field form.name %} + {% render_field form.domain %} + {% render_field form.tags %} +
+
+
+
Member Devices
+
+ {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.members %} + {% render_field form.initial_position %} +
+
+{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 4704ef613..6aa80f910 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -144,6 +144,11 @@ Platforms + {% if perms.dcim.add_virtualchassis %} +
+ +
+ {% endif %} Virtual Chassis