feat(virtualization): Allow VMs to be assigned directly to devices (#21731)

Enable VMs to be assigned to a standalone device without requiring a
cluster. Add device-scoped uniqueness constraints, update validation
logic, and enhance placement flexibility. Site is now auto-inherited
from the cluster or device.
This commit is contained in:
Martin Hauser
2026-03-25 18:20:00 +01:00
committed by GitHub
parent 29239ca58a
commit 2c0b6c4d55
10 changed files with 464 additions and 83 deletions

View File

@@ -109,7 +109,8 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
queryset=Device.objects.all(),
required=False,
query_params={
'cluster_id': '$cluster'
'cluster_id': '$cluster',
'site_id': '$site'
}
)
role = DynamicModelChoiceField(
@@ -151,7 +152,8 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
model = VirtualMachine
fieldsets = (
FieldSet('site', 'cluster', 'device', 'status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'),
FieldSet('status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'),
FieldSet('site', 'cluster', 'device', name=_('Placement')),
FieldSet('vcpus', 'memory', 'disk', name=_('Resources')),
FieldSet('config_template', name=_('Configuration')),
)
@@ -264,13 +266,21 @@ class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
interfaces = VMInterface.objects.filter(
pk__in=self.initial['pk']
).prefetch_related(
'virtual_machine__site'
'virtual_machine__site',
'virtual_machine__cluster',
'virtual_machine__device',
)
# Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop.
# Determine the effective site for each interface's VM (from its site,
# cluster, or device). If all selected interfaces share the same site,
# use it to filter VLAN choices; otherwise leave unfiltered.
for interface in interfaces:
vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster._site
vm = interface.virtual_machine
vm_site = (
vm.site
or (vm.cluster and vm.cluster._site)
or (vm.device and vm.device.site)
)
if site is None:
site = vm_site
elif vm_site is not site:

View File

@@ -99,21 +99,21 @@ class VirtualMachineImportForm(PrimaryModelImportForm):
queryset=Site.objects.all(),
to_field_name='name',
required=False,
help_text=_('Assigned site')
help_text=_('Assigned site (inferred from cluster or device if omitted)')
)
cluster = CSVModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text=_('Assigned cluster')
help_text=_('Assigned cluster (required when the device belongs to a cluster)')
)
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text=_('Assigned device within cluster')
help_text=_('Host device (standalone or within a cluster)')
)
role = CSVModelChoiceField(
label=_('Role'),

View File

@@ -171,7 +171,11 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False
required=False,
help_text=_(
'The site where this VM resides. Will be inferred automatically from the '
'assigned cluster or device if left blank.'
),
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
@@ -181,16 +185,21 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
query_params={
'site_id': ['$site', 'null']
},
help_text=_('Assign this VM to a cluster. Required when selecting a device that belongs to a cluster.'),
)
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
required=False,
selector=True,
query_params={
'cluster_id': '$cluster',
'site_id': '$site',
},
help_text=_("Optionally pin this VM to a specific host device within the cluster")
help_text=_(
'Optionally pin this VM to a specific host device within a cluster, '
'or assign it directly to a standalone device.'
)
)
role = DynamicModelChoiceField(
label=_('Role'),
@@ -218,7 +227,7 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
fieldsets = (
FieldSet('name', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags', name=_('Virtual Machine')),
FieldSet('site', 'cluster', 'device', name=_('Site/Cluster')),
FieldSet('site', 'cluster', 'device', name=_('Placement')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')),
FieldSet('vcpus', 'memory', 'disk', name=_('Resources')),

View File

@@ -0,0 +1,48 @@
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0228_cable_bundle'),
('extras', '0135_configtemplate_debug'),
('ipam', '0088_rename_vlangroup_total_vlan_ids'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
('virtualization', '0052_gfk_indexes'),
]
operations = [
migrations.AddConstraint(
model_name='virtualmachine',
constraint=models.UniqueConstraint(
django.db.models.functions.text.Lower('name'),
models.F('device'),
models.F('tenant'),
condition=models.Q(('cluster__isnull', True), ('device__isnull', False)),
name='virtualization_virtualmachine_unique_name_device_tenant',
violation_error_message='Virtual machine name must be unique per device and tenant.',
),
),
migrations.AddConstraint(
model_name='virtualmachine',
constraint=models.UniqueConstraint(
django.db.models.functions.text.Lower('name'),
models.F('device'),
condition=models.Q(('cluster__isnull', True), ('device__isnull', False), ('tenant__isnull', True)),
name='virtualization_virtualmachine_unique_name_device',
violation_error_message='Virtual machine name must be unique per device.',
),
),
migrations.AlterConstraint(
model_name='virtualmachine',
name='virtualization_virtualmachine_unique_name_cluster_tenant',
constraint=models.UniqueConstraint(
django.db.models.functions.text.Lower('name'),
models.F('cluster'),
models.F('tenant'),
name='virtualization_virtualmachine_unique_name_cluster_tenant',
violation_error_message='Virtual machine name must be unique per cluster and tenant.',
),
),
]

View File

@@ -31,7 +31,16 @@ __all__ = (
class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
"""
A virtual machine which runs inside a Cluster.
A virtual machine which runs on a Cluster or a standalone Device.
Each VM must be placed in at least one of three ways:
1. Assigned to a Site alone (e.g. for logical grouping without a specific host).
2. Assigned to a Cluster and optionally pinned to a host Device within that cluster.
3. Assigned directly to a standalone Device (one that does not belong to any cluster).
When a Cluster or Device is set, the Site is automatically inherited if not explicitly provided.
If a Device belongs to a Cluster, the Cluster must also be specified on the VM.
"""
site = models.ForeignKey(
to='dcim.Site',
@@ -155,22 +164,32 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
clone_fields = (
'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
)
prerequisite_models = (
'virtualization.Cluster',
)
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
constraints = (
models.UniqueConstraint(
Lower('name'), 'cluster', 'tenant',
name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
name='%(app_label)s_%(class)s_unique_name_cluster_tenant',
violation_error_message=_('Virtual machine name must be unique per cluster and tenant.')
),
models.UniqueConstraint(
Lower('name'), 'cluster',
name='%(app_label)s_%(class)s_unique_name_cluster',
condition=Q(tenant__isnull=True),
violation_error_message=_("Virtual machine name must be unique per cluster.")
violation_error_message=_('Virtual machine name must be unique per cluster.')
),
models.UniqueConstraint(
Lower('name'), 'device', 'tenant',
name='%(app_label)s_%(class)s_unique_name_device_tenant',
condition=Q(cluster__isnull=True, device__isnull=False),
violation_error_message=_('Virtual machine name must be unique per device and tenant.')
),
models.UniqueConstraint(
Lower('name'), 'device',
name='%(app_label)s_%(class)s_unique_name_device',
condition=Q(cluster__isnull=True, device__isnull=False, tenant__isnull=True),
violation_error_message=_('Virtual machine name must be unique per device.')
),
)
verbose_name = _('virtual machine')
@@ -182,11 +201,11 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
def clean(self):
super().clean()
# Must be assigned to a site and/or cluster
if not self.site and not self.cluster:
raise ValidationError({
'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
})
# Must be assigned to a site, cluster, and/or device
if not self.site and not self.cluster and not self.device:
raise ValidationError(
_('A virtual machine must be assigned to a site, cluster, or device.')
)
# Validate site for cluster & VM
if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site:
@@ -196,12 +215,25 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
).format(cluster=self.cluster, site=self.site)
})
# Validate assigned cluster device
if self.device and not self.cluster:
# Validate site for the device & VM (when the device is standalone)
if self.device and self.site and self.device.site and self.device.site != self.site:
raise ValidationError({
'device': _('Must specify a cluster when assigning a host device.')
'site': _(
'The selected device ({device}) is not assigned to this site ({site}).'
).format(device=self.device, site=self.site)
})
if self.device and self.device not in self.cluster.devices.all():
# Direct device assignment is only for standalone hosts. If the selected
# device already belongs to a cluster, require that cluster explicitly.
if self.device and not self.cluster and self.device.cluster:
raise ValidationError({
'cluster': _(
"Must specify the assigned device's cluster ({cluster}) when assigning host device {device}."
).format(cluster=self.device.cluster, device=self.device)
})
# Validate assigned cluster device
if self.device and self.cluster and self.device.cluster_id != self.cluster_id:
raise ValidationError({
'device': _(
"The selected device ({device}) is not assigned to this cluster ({cluster})."
@@ -243,10 +275,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
})
def save(self, *args, **kwargs):
# Assign site from cluster if not set
if self.cluster and not self.site:
self.site = self.cluster._site
# Assign a site from a cluster or device if not set
if not self.site:
if self.cluster and self.cluster._site:
self.site = self.cluster._site
elif self.device and self.device.site:
self.site = self.device.site
super().save(*args, **kwargs)

View File

@@ -3,6 +3,7 @@ from django.test import TestCase
from dcim.models import Site
from tenancy.models import Tenant
from utilities.testing import create_test_device
from virtualization.models import *
@@ -10,29 +11,77 @@ class VirtualMachineTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type)
# Create the cluster type
cls.cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
# Create sites
cls.sites = (
Site.objects.create(name='Site 1', slug='site-1'),
Site.objects.create(name='Site 2', slug='site-2'),
)
# Create clusters with various site scopes
cls.cluster_with_site = Cluster.objects.create(
name='Cluster with Site',
type=cls.cluster_type,
scope=cls.sites[0],
)
cls.cluster_with_site2 = Cluster.objects.create(
name='Cluster with Site 2',
type=cls.cluster_type,
scope=cls.sites[1],
)
cls.cluster_no_site = Cluster.objects.create(
name='Cluster No Site',
type=cls.cluster_type,
scope=None,
)
# Create devices
cls.device_in_cluster = create_test_device(
'Device in Cluster',
site=cls.sites[0],
cluster=cls.cluster_with_site,
)
cls.device_in_cluster2 = create_test_device(
'Device in Cluster 2',
site=cls.sites[0],
cluster=cls.cluster_with_site,
)
cls.standalone_device = create_test_device(
'Standalone Device',
site=cls.sites[1],
)
# Create tenants
cls.tenants = (
Tenant.objects.create(name='Tenant 1', slug='tenant-1'),
Tenant.objects.create(name='Tenant 2', slug='tenant-2'),
)
def test_vm_duplicate_name_per_cluster(self):
"""
Test that creating two Virtual Machines with the same name in
the same cluster fails validation.
"""
vm1 = VirtualMachine(
cluster=Cluster.objects.first(),
name='Test VM 1'
cluster=self.cluster_with_site,
name='Test VM 1',
)
vm1.save()
vm2 = VirtualMachine(
cluster=vm1.cluster,
name=vm1.name
name=vm1.name,
)
# Two VMs assigned to the same Cluster and no Tenant should fail validation
with self.assertRaises(ValidationError):
vm2.full_clean()
tenant = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
vm1.tenant = tenant
vm1.tenant = self.tenants[0]
vm1.save()
vm2.tenant = tenant
vm2.tenant = self.tenants[0]
# Two VMs assigned to the same Cluster and the same Tenant should fail validation
with self.assertRaises(ValidationError):
@@ -45,50 +94,38 @@ class VirtualMachineTestCase(TestCase):
vm2.save()
def test_vm_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.first()
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
clusters = (
Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, scope=None),
)
for cluster in clusters:
cluster.save()
"""
Test that creating a Virtual Machine with a mismatched site and
cluster fails validation.
"""
# VM with site only should pass
VirtualMachine(name='vm1', site=sites[0]).full_clean()
VirtualMachine(name='vm1', site=self.sites[0]).full_clean()
# VM with site, cluster non-site should pass
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[2]).full_clean()
VirtualMachine(name='vm2', site=self.sites[0], cluster=self.cluster_no_site).full_clean()
# VM with non-site cluster only should pass
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
VirtualMachine(name='vm3', cluster=self.cluster_no_site).full_clean()
# VM with mismatched site & cluster should fail
with self.assertRaises(ValidationError):
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
VirtualMachine(name='vm4', site=self.sites[0], cluster=self.cluster_with_site2).full_clean()
# VM with cluster site but no direct site should have its site set automatically
vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
# VM with a cluster site but no direct site should have its site set automatically
vm = VirtualMachine(name='vm5', site=None, cluster=self.cluster_with_site)
vm.save()
self.assertEqual(vm.site, sites[0])
self.assertEqual(vm.site, self.sites[0])
def test_vm_name_case_sensitivity(self):
vm1 = VirtualMachine(
cluster=Cluster.objects.first(),
name='virtual machine 1'
cluster=self.cluster_with_site,
name='virtual machine 1',
)
vm1.save()
vm2 = VirtualMachine(
cluster=vm1.cluster,
name='VIRTUAL MACHINE 1'
name='VIRTUAL MACHINE 1',
)
# Uniqueness validation for name should ignore case
@@ -97,8 +134,8 @@ class VirtualMachineTestCase(TestCase):
def test_disk_size(self):
vm = VirtualMachine(
cluster=Cluster.objects.first(),
name='Virtual Machine 1'
cluster=self.cluster_with_site,
name='VM Disk Test',
)
vm.save()
vm.refresh_from_db()
@@ -111,7 +148,7 @@ class VirtualMachineTestCase(TestCase):
self.assertEqual(vm.disk, 20)
# Delete one VirtualDisk
VirtualDisk.objects.first().delete()
VirtualDisk.objects.filter(virtual_machine=vm).first().delete()
vm.refresh_from_db()
self.assertEqual(vm.disk, 10)
@@ -119,3 +156,228 @@ class VirtualMachineTestCase(TestCase):
vm.disk = 30
with self.assertRaises(ValidationError):
vm.full_clean()
#
# Device assignment tests
#
def test_vm_assignment_valid_combinations(self):
"""
Test valid assignment combinations for VirtualMachine.
"""
# Valid: Site only
VirtualMachine(name='vm-site-only', site=self.sites[0]).full_clean()
# Valid: Cluster only (cluster has a site scope)
VirtualMachine(name='vm-cluster-only', cluster=self.cluster_with_site).full_clean()
# Valid: Cluster only (cluster has no site scope)
VirtualMachine(name='vm-cluster-no-site', cluster=self.cluster_no_site).full_clean()
# Valid: Device only (standalone device, no cluster)
VirtualMachine(name='vm-device-standalone', device=self.standalone_device).full_clean()
# Valid: Site + Cluster (matching)
VirtualMachine(name='vm-site-cluster', site=self.sites[0], cluster=self.cluster_with_site).full_clean()
# Valid: Site + Cluster (cluster has no site scope)
VirtualMachine(name='vm-site-cluster-no-scope', site=self.sites[0], cluster=self.cluster_no_site).full_clean()
# Valid: Cluster + Device (device belongs to the cluster)
VirtualMachine(
name='vm-cluster-device', cluster=self.cluster_with_site, device=self.device_in_cluster
).full_clean()
# Valid: Site + Cluster + Device (all matching)
VirtualMachine(
name='vm-all-three',
site=self.sites[0],
cluster=self.cluster_with_site,
device=self.device_in_cluster,
).full_clean()
def test_vm_assignment_invalid_no_assignment(self):
"""
Test that a VirtualMachine without any assignment fails validation.
"""
vm = VirtualMachine(name='vm-no-assignment')
with self.assertRaises(ValidationError) as context:
vm.full_clean()
self.assertIn('__all__', context.exception.message_dict)
def test_vm_assignment_invalid_site_cluster_mismatch(self):
"""
Test that a VirtualMachine with a mismatched site and cluster fails validation.
"""
# VM with Site 2 but Cluster scoped to Site 1 should fail
vm = VirtualMachine(name='vm-mismatch', site=self.sites[1], cluster=self.cluster_with_site)
with self.assertRaises(ValidationError) as context:
vm.full_clean()
self.assertIn('cluster', context.exception.message_dict)
def test_vm_assignment_invalid_device_in_cluster_without_cluster(self):
"""
Test that assigning a VM to a device that belongs to a cluster
without specifying the cluster fails validation.
"""
# VM assigned to a device without specifying the cluster should fail
vm = VirtualMachine(name='vm-device-no-cluster', device=self.device_in_cluster)
with self.assertRaises(ValidationError) as context:
vm.full_clean()
self.assertIn('cluster', context.exception.message_dict)
def test_vm_assignment_invalid_device_cluster_mismatch(self):
"""
Test that a VirtualMachine with a device and cluster that don't match fails validation.
"""
# VM with a device in cluster_with_site but assigned to cluster_with_site2 should fail
vm = VirtualMachine(
name='vm-device-wrong-cluster',
device=self.device_in_cluster,
cluster=self.cluster_with_site2,
)
with self.assertRaises(ValidationError) as context:
vm.full_clean()
self.assertIn('device', context.exception.message_dict)
def test_vm_standalone_device_assignment(self):
"""
Test that a VirtualMachine can be assigned directly to a standalone device
(device not in any cluster).
"""
# VM assigned to a standalone device only should pass
vm = VirtualMachine(name='vm-standalone', device=self.standalone_device)
vm.full_clean()
vm.save()
# Verify the site was automatically set from the device
self.assertEqual(vm.site, self.sites[1])
self.assertIsNone(vm.cluster)
def test_vm_standalone_device_with_site(self):
"""
Test that a VirtualMachine can be assigned to a standalone device
with an explicit matching site.
"""
# VM assigned to a standalone device with an explicit site should pass
vm = VirtualMachine(name='vm-standalone-site', site=self.sites[1], device=self.standalone_device)
vm.full_clean()
vm.save()
self.assertEqual(vm.site, self.sites[1])
self.assertEqual(vm.device, self.standalone_device)
self.assertIsNone(vm.cluster)
def test_vm_duplicate_name_per_device(self):
"""
Test that VirtualMachine names must be unique per standalone device (when no cluster).
"""
vm1 = VirtualMachine(name='vm-dup', device=self.standalone_device)
vm1.full_clean()
vm1.save()
vm2 = VirtualMachine(name='vm-dup', device=self.standalone_device)
# Duplicate name on the same standalone device should fail
with self.assertRaises(ValidationError):
vm2.full_clean()
def test_vm_site_auto_assignment_from_device(self):
"""
Test that a VirtualMachine's site is automatically set from its assigned
standalone device when no site is explicitly provided.
"""
# VM with a device only (no explicit site)
vm = VirtualMachine(name='vm-auto-site', device=self.standalone_device)
vm.full_clean()
vm.save()
# Site should be automatically inherited from the device
self.assertEqual(vm.site, self.sites[1])
def test_vm_duplicate_name_per_device_with_tenant(self):
"""
Test that VirtualMachine names can be duplicated across different tenants
on the same standalone device.
"""
# Create VM with tenant1
vm1 = VirtualMachine(name='vm-tenant-test', device=self.standalone_device, tenant=self.tenants[0])
vm1.full_clean()
vm1.save()
# The same name with tenant2 on the same device should pass
vm2 = VirtualMachine(name='vm-tenant-test', device=self.standalone_device, tenant=self.tenants[1])
vm2.full_clean()
vm2.save()
# The same name with the same tenant should fail
vm3 = VirtualMachine(name='vm-tenant-test', device=self.standalone_device, tenant=self.tenants[0])
with self.assertRaises(ValidationError):
vm3.full_clean()
def test_vm_device_name_case_sensitivity(self):
"""
Test that VirtualMachine name uniqueness per device is case-insensitive.
"""
vm1 = VirtualMachine(name='test vm', device=self.standalone_device)
vm1.full_clean()
vm1.save()
# The same name with a different case should fail
vm2 = VirtualMachine(name='TEST VM', device=self.standalone_device)
with self.assertRaises(ValidationError):
vm2.full_clean()
def test_vm_cluster_device_with_site(self):
"""
Test that a VirtualMachine can be pinned to a device within a cluster
with an explicit matching site.
"""
# VM with site + cluster + device (all matching)
vm = VirtualMachine(
name='vm-cluster-device-site',
site=self.sites[0],
cluster=self.cluster_with_site,
device=self.device_in_cluster,
)
vm.full_clean()
vm.save()
self.assertEqual(vm.site, self.sites[0])
self.assertEqual(vm.cluster, self.cluster_with_site)
self.assertEqual(vm.device, self.device_in_cluster)
def test_vm_move_between_devices_in_cluster(self):
"""
Test that a VirtualMachine can be moved between devices within the same cluster.
"""
# Create a VM pinned to device_in_cluster
vm = VirtualMachine(name='vm-movable', cluster=self.cluster_with_site, device=self.device_in_cluster)
vm.full_clean()
vm.save()
# Move VM to device_in_cluster2
vm.device = self.device_in_cluster2
vm.full_clean()
vm.save()
self.assertEqual(vm.device, self.device_in_cluster2)
self.assertEqual(vm.cluster, self.cluster_with_site)
def test_vm_unpin_from_device(self):
"""
Test that a VirtualMachine can be unpinned from a device while remaining
in the cluster.
"""
# Create a VM pinned to a device
vm = VirtualMachine(name='vm-unpinnable', cluster=self.cluster_with_site, device=self.device_in_cluster)
vm.full_clean()
vm.save()
# Unpin VM from the device (keep in cluster)
vm.device = None
vm.full_clean()
vm.save()
self.assertIsNone(vm.device)
self.assertEqual(vm.cluster, self.cluster_with_site)

View File

@@ -35,8 +35,8 @@ class VirtualMachinePanel(panels.ObjectAttributesPanel):
)
class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
title = _('Cluster')
class VirtualMachinePlacementPanel(panels.ObjectAttributesPanel):
title = _('Placement')
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)

View File

@@ -412,7 +412,7 @@ class VirtualMachineView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
panels.VirtualMachineClusterPanel(),
panels.VirtualMachinePlacementPanel(),
TemplatePanel('virtualization/panels/virtual_machine_resources.html'),
ObjectsTablePanel(
model='ipam.Service',