2
0
Эх сурвалжийг харах

fix(dcim): Prevent validation error when VDC has no device assigned (#23279)

Add device_id check before calling vc_interfaces() to avoid
AttributeError when validating primary IP on VirtualDeviceContext
without an assigned device. Includes regression tests for model
validation and view workflows.

Fixes #23275
Martin Hauser 1 өдөр өмнө
parent
commit
fe4981176d

+ 1 - 1
netbox/dcim/models/devices.py

@@ -1405,7 +1405,7 @@ class VirtualDeviceContext(PrimaryModel):
                         "{ip} is not an IPv{family} address."
                     ).format(family=family, ip=primary_ip)
                 })
-            device_interfaces = self.device.vc_interfaces(if_master=False)
+            device_interfaces = self.device.vc_interfaces(if_master=False) if self.device_id else []
             if primary_ip.assigned_object not in device_interfaces:
                 raise ValidationError({
                     f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')

+ 18 - 0
netbox/dcim/tests/test_models.py

@@ -3192,6 +3192,24 @@ class VirtualDeviceContextTestCase(TestCase):
         with self.assertRaises(ValidationError):
             vdc2.full_clean()
 
+    @tag('regression')  # Ref: #23275
+    def test_primary_ip_requires_device(self):
+        """A primary IP on the device's interface is valid only while that device is assigned."""
+        device = Device.objects.first()
+        interface = Interface.objects.create(device=device, name='Eth1/1', type='10gbase-t')
+
+        for family, address in ((4, '192.0.2.1/24'), (6, '2001:db8::1/64')):
+            field = f'primary_ip{family}'
+            ip = IPAddress.objects.create(address=address, assigned_object=interface)
+            with self.subTest(family=family):
+                VirtualDeviceContext(device=device, name='VDC 1', status='active', **{field: ip}).full_clean()
+
+                vdc = VirtualDeviceContext(name='VDC 1', status='active', **{field: ip})
+                with self.assertRaises(ValidationError) as cm:
+                    vdc.full_clean()
+                self.assertIn(field, cm.exception.message_dict)
+                self.assertIn('must belong to an interface', str(cm.exception.message_dict[field]))
+
 
 class VirtualChassisTestCase(TestCase):
 

+ 36 - 1
netbox/dcim/tests/test_views.py

@@ -22,7 +22,7 @@ from dcim.constants import *
 from dcim.models import *
 from dcim.views import DeviceTypeListView, ModuleTypeListView
 from extras.models import ConfigContext, ConfigTemplate
-from ipam.models import ASN, RIR, VLAN, VRF, VLANGroup
+from ipam.models import ASN, RIR, VLAN, VRF, IPAddress, VLANGroup
 from netbox.choices import (
     CSVDelimiterChoices,
     DiameterUnitChoices,
@@ -6155,6 +6155,41 @@ class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             self.assertEqual(vdc.device, device, msg=f"Device was unexpectedly cleared on VDC '{vdc.name}'")
             self.assertEqual(vdc.status, VirtualDeviceContextStatusChoices.STATUS_PLANNED)
 
+    @tag('regression')  # Ref: #23275
+    def test_create_with_primary_ip_without_device(self):
+        """A direct add POST with a primary IP but no device re-renders the form with an error."""
+        self.add_permissions('dcim.add_virtualdevicecontext', 'ipam.view_ipaddress')
+        ip = IPAddress.objects.create(address='192.0.2.1/24')
+
+        response = self.client.post(reverse('dcim:virtualdevicecontext_add'), {
+            'name': 'VDC 4',
+            'status': 'active',
+            'primary_ip4': ip.pk,
+        })
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'Primary IP address must belong to an interface on the assigned device.')
+        self.assertFalse(VirtualDeviceContext.objects.filter(name='VDC 4').exists())
+
+    @tag('regression')  # Ref: #23275
+    def test_bulk_edit_clear_device_with_primary_ip(self):
+        """Bulk clearing the device of a context with a primary IP fails validation and changes nothing."""
+        self.add_permissions('dcim.view_virtualdevicecontext', 'dcim.change_virtualdevicecontext')
+        device = Device.objects.get(name='Device 1')
+        interface = Interface.objects.create(device=device, name='Interface 1', type='1000base-t')
+        vdc = VirtualDeviceContext.objects.get(name='VDC 1')
+        vdc.primary_ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
+        vdc.save()
+
+        response = self.client.post(reverse('dcim:virtualdevicecontext_bulk_edit'), {
+            'pk': [vdc.pk],
+            '_apply': True,
+            '_nullify': ['device'],
+        })
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'Primary IP address must belong to an interface on the assigned device.')
+        vdc.refresh_from_db()
+        self.assertEqual(vdc.device, device)
+
 
 class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = MACAddress