Просмотр исходного кода

Merge pull request #4787 from netbox-community/2018-virtual-chassis-name

#2018: Add name field to VirtualChassis model
Jeremy Stretch 5 лет назад
Родитель
Сommit
2ceed475d5

+ 1 - 1
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']
 
 
 #

+ 2 - 2
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']
 
 
 #

+ 81 - 2
netbox/dcim/forms.py

@@ -4114,7 +4114,68 @@ class DeviceSelectionForm(forms.Form):
     )
 
 
+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
+    )
+
+    class Meta:
+        model = VirtualChassis
+        fields = [
+            '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
@@ -4123,12 +4184,17 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = VirtualChassis
         fields = [
-            'master', 'domain', 'tags',
+            '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
 
@@ -4240,6 +4306,19 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
         nullable_fields = ['domain']
 
 
+class VirtualChassisCSVForm(CSVModelForm):
+    master = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Master device'
+    )
+
+    class Meta:
+        model = VirtualChassis
+        fields = VirtualChassis.csv_headers
+
+
 class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VirtualChassis
     q = forms.CharField(

+ 46 - 0
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),
+        ),
+    ]

+ 18 - 13
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,
         )
 
 

+ 6 - 7
netbox/dcim/signals.py

@@ -10,14 +10,13 @@ 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:
+        master = Device.objects.get(pk=instance.master.pk)
+        master.virtual_chassis = instance
+        master.vc_position = 1
+        master.save()
 
 
 @receiver(pre_delete, sender=VirtualChassis)

+ 5 - 3
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')
 
 
 #

+ 9 - 8
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',
             },
         ]

+ 20 - 17
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
@@ -1587,7 +1578,6 @@ class VirtualChassisTestCase(
             name='Device Role', slug='device-role-1'
         )
 
-        # Create 9 member Devices
         devices = (
             Device(device_type=device_type, device_role=device_role, name='Device 1', site=site),
             Device(device_type=device_type, device_role=device_role, name='Device 2', site=site),
@@ -1598,23 +1588,29 @@ class VirtualChassisTestCase(
             Device(device_type=device_type, device_role=device_role, name='Device 7', site=site),
             Device(device_type=device_type, device_role=device_role, name='Device 8', site=site),
             Device(device_type=device_type, device_role=device_role, name='Device 9', site=site),
+            Device(device_type=device_type, device_role=device_role, name='Device 10', site=site),
+            Device(device_type=device_type, device_role=device_role, name='Device 11', site=site),
+            Device(device_type=device_type, device_role=device_role, name='Device 12', site=site),
         )
         Device.objects.bulk_create(devices)
 
-        # Create three VirtualChassis with two members each
-        vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1')
+        # Create three VirtualChassis with three members each
+        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,
@@ -1622,6 +1618,13 @@ class VirtualChassisTestCase(
             'form-MAX_NUM_FORMS': 1000,
         }
 
+        cls.csv_data = (
+            "name,domain,master",
+            "VC4,Domain 4,Device 10",
+            "VC5,Domain 5,Device 11",
+            "VC6,Domain 6,Device 12",
+        )
+
         cls.bulk_edit_data = {
             'domain': 'domain-x',
         }

+ 1 - 0
netbox/dcim/urls.py

@@ -321,6 +321,7 @@ urlpatterns = [
     # Virtual chassis
     path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
     path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
+    path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'),
     path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
     path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
     path('virtual-chassis/<int:pk>/', views.VirtualChassisView.as_view(), name='virtualchassis'),

+ 12 - 56
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,
@@ -2355,6 +2304,13 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         })
 
 
+class VirtualChassisBulkImportView(BulkImportView):
+    queryset = VirtualChassis.objects.all()
+    model_form = forms.VirtualChassisCSVForm
+    table = tables.VirtualChassisTable
+    default_return_url = 'dcim:virtualchassis_list'
+
+
 class VirtualChassisBulkEditView(BulkEditView):
     queryset = VirtualChassis.objects.all()
     filterset = filters.VirtualChassisFilterSet

+ 0 - 5
netbox/templates/dcim/device_list.html

@@ -17,9 +17,4 @@
             </ul>
         </div>
     {% endif %}
-    {% if perms.dcim.add_virtualchassis %}
-        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
-        </button>
-    {% endif %}
 {% endblock %}

+ 14 - 2
netbox/templates/dcim/virtualchassis.html

@@ -9,7 +9,9 @@
         <div class="col-sm-8 col-md-9">
             <ol class="breadcrumb">
                 <li><a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a></li>
-                <li><a href="{% url 'dcim:virtualchassis_list' %}?site={{ virtualchassis.master.site.slug }}">{{ virtualchassis.master.site }}</a></li>
+                {% if virtualchassis.master %}
+                    <li><a href="{% url 'dcim:virtualchassis_list' %}?site={{ virtualchassis.master.site.slug }}">{{ virtualchassis.master.site }}</a></li>
+                {% endif %}
                 <li>{{ virtualchassis }}</li>
             </ol>
         </div>
@@ -63,7 +65,17 @@
                 <tr>
                     <td>Domain</td>
                     <td>{{ virtualchassis.domain|placeholder }}</td>
-
+                </tr>
+                <tr>
+                    <td>Master</td>
+                    <td>
+                        {% if virtualchassis.master %}
+                            <a href="{{ virtualchassis.master.get_absolute_url }}">{{ virtualchassis.master }}</a>
+                        {% else %}
+                            <span class="text-muted">&mdash;</span>
+                        {% endif %}
+                    </td>
+                </tr>
             </table>
         </div>
         {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %}

+ 22 - 0
netbox/templates/dcim/virtualchassis_add.html

@@ -0,0 +1,22 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Virtual Chassis</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.domain %}
+            {% render_field form.tags %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Member Devices</strong></div>
+        <div class="panel-body">
+            {% render_field form.site %}
+            {% render_field form.rack %}
+            {% render_field form.members %}
+            {% render_field form.initial_position %}
+        </div>
+    </div>
+{% endblock %}

+ 6 - 0
netbox/templates/inc/nav_menu.html

@@ -144,6 +144,12 @@
                             <a href="{% url 'dcim:platform_list' %}">Platforms</a>
                         </li>
                         <li{% if not perms.dcim.view_virtualchassis %} class="disabled"{% endif %}>
+                            {% if perms.dcim.add_virtualchassis %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:virtualchassis_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                    <a href="{% url 'dcim:virtualchassis_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
                             <a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
                         </li>
                         <li class="divider"></li>