Преглед изворни кода

Closes #8222: Enable the assignment of a VM to a specific host device within a cluster

jeremystretch пре 3 година
родитељ
комит
b331f047af

+ 1 - 1
docs/models/virtualization/virtualmachine.md

@@ -1,6 +1,6 @@
 # Virtual Machines
 # Virtual Machines
 
 
-A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
+A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster, and may optionally be assigned to a particular host device within that cluster.
 
 
 Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:
 Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:
 
 

+ 4 - 1
docs/release-notes/version-3.3.md

@@ -9,6 +9,7 @@
 ### Enhancements
 ### Enhancements
 
 
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
+* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
 * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
 * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
 * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
 * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
@@ -26,4 +27,6 @@
     * The `nat_inside` field no longer requires a unique value
     * The `nat_inside` field no longer requires a unique value
     * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
     * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
 * virtualization.Cluster
 * virtualization.Cluster
-    * Add required `status` field (default value: `active`)
+    * Added required `status` field (default value: `active`)
+* virtualization.VirtualMachine
+    * Added `device` field

+ 8 - 6
netbox/templates/virtualization/virtualmachine.html

@@ -78,9 +78,7 @@
     </div>
     </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
         <div class="card">
         <div class="card">
-            <h5 class="card-header">
-                Cluster
-            </h5>
+            <h5 class="card-header">Cluster</h5>
             <div class="card-body">
             <div class="card-body">
                 <table class="table table-hover attr-table">
                 <table class="table table-hover attr-table">
                     <tr>
                     <tr>
@@ -96,13 +94,17 @@
                         <th scope="row">Cluster Type</th>
                         <th scope="row">Cluster Type</th>
                         <td>{{ object.cluster.type }}</td>
                         <td>{{ object.cluster.type }}</td>
                     </tr>
                     </tr>
+                    <tr>
+                        <th scope="row">Device</th>
+                        <td>
+                            {{ object.device|linkify|placeholder }}
+                        </td>
+                    </tr>
                 </table>
                 </table>
             </div>
             </div>
         </div>
         </div>
         <div class="card">
         <div class="card">
-            <h5 class="card-header">
-                Resources
-            </h5>
+            <h5 class="card-header">Resources</h5>
             <div class="card-body">
             <div class="card-body">
                 <table class="table table-hover attr-table">
                 <table class="table table-hover attr-table">
                     <tr>
                     <tr>

+ 2 - 2
netbox/utilities/testing/utils.py

@@ -34,7 +34,7 @@ def post_data(data):
     return ret
     return ret
 
 
 
 
-def create_test_device(name):
+def create_test_device(name, **attrs):
     """
     """
     Convenience method for creating a Device (e.g. for component testing).
     Convenience method for creating a Device (e.g. for component testing).
     """
     """
@@ -42,7 +42,7 @@ def create_test_device(name):
     manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
     manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
     devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
     devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
     devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
     devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
-    device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
+    device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs)
 
 
     return device
     return device
 
 

+ 10 - 7
netbox/virtualization/api/serializers.py

@@ -1,7 +1,9 @@
 from drf_yasg.utils import swagger_serializer_method
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
+from dcim.api.nested_serializers import (
+    NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
+)
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
@@ -68,6 +70,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
     site = NestedSiteSerializer(read_only=True)
     cluster = NestedClusterSerializer()
     cluster = NestedClusterSerializer()
+    device = NestedDeviceSerializer(required=False, allow_null=True)
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
@@ -78,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
-            'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
+            'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         validators = []
         validators = []
 
 
@@ -90,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 
 
     class Meta(VirtualMachineSerializer.Meta):
     class Meta(VirtualMachineSerializer.Meta):
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
-            'custom_fields', 'config_context', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
+            'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
         ]
         ]
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)

+ 1 - 1
netbox/virtualization/api/views.py

@@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
 
 
 class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
 class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
     queryset = VirtualMachine.objects.prefetch_related(
     queryset = VirtualMachine.objects.prefetch_related(
-        'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+        'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
     )
     )
     filterset_class = filtersets.VirtualMachineFilterSet
     filterset_class = filtersets.VirtualMachineFilterSet
 
 

+ 11 - 1
netbox/virtualization/filtersets.py

@@ -1,7 +1,7 @@
 import django_filters
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -150,6 +150,16 @@ class VirtualMachineFilterSet(
         to_field_name='name',
         to_field_name='name',
         label='Cluster',
         label='Cluster',
     )
     )
+    device_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Device.objects.all(),
+        label='Device (ID)',
+    )
+    device = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__name',
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label='Device',
+    )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='cluster__site__region',
         field_name='cluster__site__region',

+ 10 - 3
netbox/virtualization/forms/bulk_edit.py

@@ -2,7 +2,7 @@ from django import forms
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from ipam.models import VLAN, VRF
 from ipam.models import VLAN, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -110,6 +110,13 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         required=False
         required=False
     )
     )
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'cluster_id': '$cluster'
+        }
+    )
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
         queryset=DeviceRole.objects.filter(
         queryset=DeviceRole.objects.filter(
             vm_role=True
             vm_role=True
@@ -146,11 +153,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
-        (None, ('cluster', 'status', 'role', 'tenant', 'platform')),
+        (None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')),
         ('Resources', ('vcpus', 'memory', 'disk'))
         ('Resources', ('vcpus', 'memory', 'disk'))
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+        'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
     )
     )
 
 
 
 

+ 8 - 2
netbox/virtualization/forms/bulk_import.py

@@ -1,5 +1,5 @@
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
-from dcim.models import DeviceRole, Platform, Site
+from dcim.models import Device, DeviceRole, Platform, Site
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelCSVForm
 from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -76,6 +76,12 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
         to_field_name='name',
         to_field_name='name',
         help_text='Assigned cluster'
         help_text='Assigned cluster'
     )
     )
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned device within cluster'
+    )
     role = CSVModelChoiceField(
     role = CSVModelChoiceField(
         queryset=DeviceRole.objects.filter(
         queryset=DeviceRole.objects.filter(
             vm_role=True
             vm_role=True
@@ -100,7 +106,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
-            'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+            'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
         )
         )
 
 
 
 

+ 7 - 2
netbox/virtualization/forms/filtersets.py

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import LocalConfigContextFilterForm
 from extras.forms import LocalConfigContextFilterForm
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
@@ -87,7 +87,7 @@ class VirtualMachineFilterForm(
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
-        ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
+        ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -110,6 +110,11 @@ class VirtualMachineFilterForm(
         required=False,
         required=False,
         label=_('Cluster')
         label=_('Cluster')
     )
     )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label=_('Device')
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,

+ 10 - 3
netbox/virtualization/forms/models.py

@@ -179,6 +179,13 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
             'group_id': '$cluster_group'
             'group_id': '$cluster_group'
         }
         }
     )
     )
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'cluster_id': '$cluster'
+        }
+    )
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         required=False,
         required=False,
@@ -197,7 +204,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
 
 
     fieldsets = (
     fieldsets = (
         ('Virtual Machine', ('name', 'role', 'status', 'tags')),
         ('Virtual Machine', ('name', 'role', 'status', 'tags')),
-        ('Cluster', ('cluster_group', 'cluster')),
+        ('Cluster', ('cluster_group', 'cluster', 'device')),
         ('Tenancy', ('tenant_group', 'tenant')),
         ('Tenancy', ('tenant_group', 'tenant')),
         ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
         ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
         ('Resources', ('vcpus', 'memory', 'disk')),
         ('Resources', ('vcpus', 'memory', 'disk')),
@@ -207,8 +214,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
-            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
-            'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
+            'name', 'status', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform',
+            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
         ]
         ]
         help_texts = {
         help_texts = {
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "

+ 20 - 0
netbox/virtualization/migrations/0031_virtualmachine_device.py

@@ -0,0 +1,20 @@
+# Generated by Django 4.0.4 on 2022-05-25 19:30
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0153_created_datetimefield'),
+        ('virtualization', '0030_cluster_status'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='virtualmachine',
+            name='device',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
+        ),
+    ]

+ 13 - 0
netbox/virtualization/models.py

@@ -200,6 +200,13 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='virtual_machines'
         related_name='virtual_machines'
     )
     )
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.PROTECT,
+        related_name='virtual_machines',
+        blank=True,
+        null=True
+    )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -316,6 +323,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        # Validate assigned cluster device
+        if self.device and self.device not in self.cluster.devices.all():
+            raise ValidationError({
+                'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
+            })
+
         # Validate primary IP addresses
         # Validate primary IP addresses
         interfaces = self.interfaces.all()
         interfaces = self.interfaces.all()
         for field in ['primary_ip4', 'primary_ip6']:
         for field in ['primary_ip4', 'primary_ip6']:

+ 4 - 1
netbox/virtualization/tables/virtualmachines.py

@@ -33,6 +33,9 @@ class VirtualMachineTable(NetBoxTable):
     cluster = tables.Column(
     cluster = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    device = tables.Column(
+        linkify=True
+    )
     role = columns.ColoredLabelColumn()
     role = columns.ColoredLabelColumn()
     tenant = TenantColumn()
     tenant = TenantColumn()
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
@@ -56,7 +59,7 @@ class VirtualMachineTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
-            'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
+            'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
             'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
             'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

+ 10 - 2
netbox/virtualization/tests/test_api.py

@@ -3,7 +3,7 @@ from rest_framework import status
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from ipam.models import VLAN, VRF
 from ipam.models import VLAN, VRF
-from utilities.testing import APITestCase, APIViewTestCases
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
@@ -152,8 +152,15 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
         )
         )
         Cluster.objects.bulk_create(clusters)
         Cluster.objects.bulk_create(clusters)
 
 
+        device1 = create_test_device('device1')
+        device1.cluster = clusters[0]
+        device1.save()
+        device2 = create_test_device('device2')
+        device2.cluster = clusters[1]
+        device2.save()
+
         virtual_machines = (
         virtual_machines = (
-            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}),
+            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=device1, local_context_data={'A': 1}),
             VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
             VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
             VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
             VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
         )
         )
@@ -163,6 +170,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'Virtual Machine 4',
                 'name': 'Virtual Machine 4',
                 'cluster': clusters[1].pk,
                 'cluster': clusters[1].pk,
+                'device': device2.pk,
             },
             },
             {
             {
                 'name': 'Virtual Machine 5',
                 'name': 'Virtual Machine 5',

+ 21 - 8
netbox/virtualization/tests/test_filtersets.py

@@ -1,9 +1,9 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from ipam.models import IPAddress, VRF
 from ipam.models import IPAddress, VRF
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.testing import ChangeLoggedFilterSetTests
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.filtersets import *
 from virtualization.filtersets import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -225,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
             site_group.save()
             site_group.save()
 
 
         sites = (
         sites = (
-            Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
-            Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
-            Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -252,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         DeviceRole.objects.bulk_create(roles)
         DeviceRole.objects.bulk_create(roles)
 
 
+        devices = (
+            create_test_device('device1', cluster=clusters[0]),
+            create_test_device('device2', cluster=clusters[1]),
+            create_test_device('device3', cluster=clusters[2]),
+        )
+
         tenant_groups = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -268,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         vms = (
         vms = (
-            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
-            VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
-            VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
+            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
+            VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
+            VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
         )
         )
         VirtualMachine.objects.bulk_create(vms)
         VirtualMachine.objects.bulk_create(vms)
 
 
@@ -331,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'cluster': [clusters[0].name, clusters[1].name]}
         params = {'cluster': [clusters[0].name, clusters[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_device(self):
+        devices = Device.objects.all()[:2]
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}

+ 15 - 8
netbox/virtualization/tests/test_views.py

@@ -5,7 +5,7 @@ from netaddr import EUI
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import DeviceRole, Platform, Site
 from dcim.models import DeviceRole, Platform, Site
 from ipam.models import VLAN, VRF
 from ipam.models import VLAN, VRF
-from utilities.testing import ViewTestCases, create_tags
+from utilities.testing import ViewTestCases, create_tags, create_test_device
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
@@ -176,16 +176,22 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Cluster.objects.bulk_create(clusters)
         Cluster.objects.bulk_create(clusters)
 
 
+        devices = (
+            create_test_device('device1', cluster=clusters[0]),
+            create_test_device('device2', cluster=clusters[1]),
+        )
+
         VirtualMachine.objects.bulk_create([
         VirtualMachine.objects.bulk_create([
-            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
-            VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
-            VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
+            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+            VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+            VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
         ])
         ])
 
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'cluster': clusters[1].pk,
             'cluster': clusters[1].pk,
+            'device': devices[1].pk,
             'tenant': None,
             'tenant': None,
             'platform': platforms[1].pk,
             'platform': platforms[1].pk,
             'name': 'Virtual Machine X',
             'name': 'Virtual Machine X',
@@ -202,14 +208,15 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "name,status,cluster",
-            "Virtual Machine 4,active,Cluster 1",
-            "Virtual Machine 5,active,Cluster 1",
-            "Virtual Machine 6,active,Cluster 1",
+            "name,status,cluster,device",
+            "Virtual Machine 4,active,Cluster 1,device1",
+            "Virtual Machine 5,active,Cluster 1,device1",
+            "Virtual Machine 6,active,Cluster 1,",
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'cluster': clusters[1].pk,
             'cluster': clusters[1].pk,
+            'device': devices[1].pk,
             'tenant': None,
             'tenant': None,
             'platform': platforms[1].pk,
             'platform': platforms[1].pk,
             'status': VirtualMachineStatusChoices.STATUS_STAGED,
             'status': VirtualMachineStatusChoices.STATUS_STAGED,