diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d5cc0e85651..c12ddccde67 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -465,7 +465,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm): label=_('Cluster'), queryset=Cluster.objects.all(), required=False, - selector=True + selector=True, + query_params={ + 'site_id': ['$site', 'null'] + }, ) comments = CommentField() local_context_data = JSONField( diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index cab1760ed10..9056a66c07f 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -8,6 +8,7 @@ from extras.models import CustomField from tenancy.models import Tenant from utilities.data import drange +from virtualization.models import Cluster, ClusterType class LocationTestCase(TestCase): @@ -533,6 +534,36 @@ def test_device_duplicate_names(self): device2.full_clean() device2.save() + def test_device_mismatched_site_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + Cluster.objects.create(name='Cluster 1', type=cluster_type) + + 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, site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, site=None), + ) + Cluster.objects.bulk_create(clusters) + + device_type = DeviceType.objects.first() + device_role = DeviceRole.objects.first() + + # Device with site only should pass + Device(name='device1', site=sites[0], device_type=device_type, role=device_role).full_clean() + + # Device with site, cluster non-site should pass + Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[2]).full_clean() + + # Device with mismatched site & cluster should fail + with self.assertRaises(ValidationError): + Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean() + class CableTestCase(TestCase): diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index bfdfc9ada04..5ea8a4614aa 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -178,8 +178,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, selector=True, query_params={ - 'site_id': '$site', - } + 'site_id': ['$site', 'null'] + }, ) device = DynamicModelChoiceField( label=_('Device'), diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 92f1a947234..2ca1599bfe4 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -180,7 +180,7 @@ def clean(self): }) # Validate site for cluster & device - if self.cluster and self.site and self.cluster.site != self.site: + if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: raise ValidationError({ 'cluster': _( 'The selected cluster ({cluster}) is not assigned to this site ({site}).' diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index c94ff930eae..a4e8d794720 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -63,6 +63,9 @@ def test_vm_mismatched_site_cluster(self): # VM with site only should pass VirtualMachine(name='vm1', site=sites[0]).full_clean() + # VM with site, cluster non-site should pass + VirtualMachine(name='vm1', site=sites[0], cluster=clusters[2]).full_clean() + # VM with non-site cluster only should pass VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()