Parcourir la source

Closes #12135: Prevent the deletion of interfaces with children (#14091)

* Closes #12135: Prevent the deletion of interfaces with children

* Change PROTECT to RESTRICT

* Extend handle_protectederror() to also handle RestrictedError

* Fix string translation

* Update migrations

* Support bulk removal of parent interfaces via UI if all children are included

* Add support for the bulk deletion of restricted objects via REST API
Jeremy Stretch il y a 2 ans
Parent
commit
944008d475

+ 3 - 0
docs/models/dcim/interface.md

@@ -77,6 +77,9 @@ If selected, this component will be treated as if a cable has been connected.
 
 Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
 
+!!! note
+    An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
+
 ### Bridged Interface
 
 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

+ 3 - 0
docs/models/virtualization/vminterface.md

@@ -16,6 +16,9 @@ The interface's name. Must be unique to the assigned VM.
 
 Identifies the parent interface of a subinterface (e.g. used to employ encapsulation).
 
+!!! note
+    An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
+
 ### Bridged Interface
 
 An interface on the same VM with which this interface is bridged.

+ 5 - 0
netbox/dcim/api/views.py

@@ -24,6 +24,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
+from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine
 from . import serializers
@@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     filterset_class = filtersets.InterfaceFilterSet
     brief_prefetch_fields = ['device']
 
+    def get_bulk_destroy_queryset(self):
+        # Ensure child interfaces are deleted prior to their parents
+        return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name'))
+
 
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = FrontPort.objects.prefetch_related(

+ 19 - 0
netbox/dcim/migrations/0183_protect_child_interfaces.py

@@ -0,0 +1,19 @@
+# Generated by Django 4.2.6 on 2023-10-20 11:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0182_devicetype_exclude_from_utilization'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='interface',
+            name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='dcim.interface'),
+        ),
+    ]

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

@@ -537,7 +537,7 @@ class BaseInterface(models.Model):
     )
     parent = models.ForeignKey(
         to='self',
-        on_delete=models.SET_NULL,
+        on_delete=models.RESTRICT,
         related_name='child_interfaces',
         null=True,
         blank=True,

+ 27 - 0
netbox/dcim/tests/test_api.py

@@ -1607,6 +1607,33 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
         ]
 
+    def test_bulk_delete_child_interfaces(self):
+        interface1 = Interface.objects.get(name='Interface 1')
+        device = interface1.device
+        self.add_permissions('dcim.delete_interface')
+
+        # Create a child interface
+        child = Interface.objects.create(
+            device=device,
+            name='Interface 1A',
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            parent=interface1
+        )
+        self.assertEqual(device.interfaces.count(), 4)
+
+        # Attempt to delete only the parent interface
+        url = self._get_detail_url(interface1)
+        self.client.delete(url, **self.header)
+        self.assertEqual(device.interfaces.count(), 4)  # Parent was not deleted
+
+        # Attempt to bulk delete parent & child together
+        data = [
+            {"id": interface1.pk},
+            {"id": child.pk},
+        ]
+        self.client.delete(self._get_list_url(), data, format='json', **self.header)
+        self.assertEqual(device.interfaces.count(), 2)  # Child & parent were both deleted
+
 
 class FrontPortTest(APIViewTestCases.APIViewTestCase):
     model = FrontPort

+ 30 - 0
netbox/dcim/tests/test_views.py

@@ -2531,6 +2531,36 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
         self.assertHttpStatus(response, 200)
 
+    def test_bulk_delete_child_interfaces(self):
+        interface1 = Interface.objects.get(name='Interface 1')
+        device = interface1.device
+        self.add_permissions('dcim.delete_interface')
+
+        # Create a child interface
+        child = Interface.objects.create(
+            device=device,
+            name='Interface 1A',
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            parent=interface1
+        )
+        self.assertEqual(device.interfaces.count(), 6)
+
+        # Attempt to delete only the parent interface
+        data = {
+            'confirm': True,
+        }
+        self.client.post(self._get_url('delete', interface1), data)
+        self.assertEqual(device.interfaces.count(), 6)  # Parent was not deleted
+
+        # Attempt to bulk delete parent & child together
+        data = {
+            'pk': [interface1.pk, child.pk],
+            'confirm': True,
+            '_confirm': True,  # Form button
+        }
+        self.client.post(self._get_url('bulk_delete'), data)
+        self.assertEqual(device.interfaces.count(), 4)  # Child & parent were both deleted
+
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort

+ 3 - 2
netbox/dcim/views.py

@@ -1,5 +1,4 @@
 import traceback
-from collections import defaultdict
 
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
@@ -26,6 +25,7 @@ from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
+from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
 from virtualization.models import VirtualMachine
@@ -2562,7 +2562,8 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
 
 
 class InterfaceBulkDeleteView(generic.BulkDeleteView):
-    queryset = Interface.objects.all()
+    # Ensure child interfaces are deleted prior to their parents
+    queryset = Interface.objects.order_by('device', 'parent', CollateAsChar('_name'))
     filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
 

+ 6 - 3
netbox/netbox/api/viewsets/__init__.py

@@ -2,7 +2,7 @@ import logging
 
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
-from django.db.models import ProtectedError
+from django.db.models import ProtectedError, RestrictedError
 from django_pglocks import advisory_lock
 from netbox.constants import ADVISORY_LOCK_KEYS
 from rest_framework import mixins as drf_mixins
@@ -91,8 +91,11 @@ class NetBoxModelViewSet(
 
         try:
             return super().dispatch(request, *args, **kwargs)
-        except ProtectedError as e:
-            protected_objects = list(e.protected_objects)
+        except (ProtectedError, RestrictedError) as e:
+            if type(e) is ProtectedError:
+                protected_objects = list(e.protected_objects)
+            else:
+                protected_objects = list(e.restricted_objects)
             msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
             msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
             logger.warning(msg)

+ 8 - 2
netbox/netbox/api/viewsets/mixins.py

@@ -137,11 +137,14 @@ class BulkUpdateModelMixin:
         }
     ]
     """
+    def get_bulk_update_queryset(self):
+        return self.get_queryset()
+
     def bulk_update(self, request, *args, **kwargs):
         partial = kwargs.pop('partial', False)
         serializer = BulkOperationSerializer(data=request.data, many=True)
         serializer.is_valid(raise_exception=True)
-        qs = self.get_queryset().filter(
+        qs = self.get_bulk_update_queryset().filter(
             pk__in=[o['id'] for o in serializer.data]
         )
 
@@ -184,10 +187,13 @@ class BulkDestroyModelMixin:
         {"id": 456}
     ]
     """
+    def get_bulk_destroy_queryset(self):
+        return self.get_queryset()
+
     def bulk_destroy(self, request, *args, **kwargs):
         serializer = BulkOperationSerializer(data=request.data, many=True)
         serializer.is_valid(raise_exception=True)
-        qs = self.get_queryset().filter(
+        qs = self.get_bulk_destroy_queryset().filter(
             pk__in=[o['id'] for o in serializer.data]
         )
 

+ 10 - 9
netbox/netbox/views/generic/bulk_views.py

@@ -7,7 +7,7 @@ from django.contrib.contenttypes.fields import GenericRel
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
-from django.db.models import ManyToManyField, ProtectedError
+from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models.fields.reverse_related import ManyToManyRel
 from django.forms import HiddenInput, ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse
@@ -798,14 +798,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
                 queryset = self.queryset.filter(pk__in=pk_list)
                 deleted_count = queryset.count()
                 try:
-                    for obj in queryset:
-                        # Take a snapshot of change-logged models
-                        if hasattr(obj, 'snapshot'):
-                            obj.snapshot()
-                        obj.delete()
-
-                except ProtectedError as e:
-                    logger.info("Caught ProtectedError while attempting to delete objects")
+                    with transaction.atomic():
+                        for obj in queryset:
+                            # Take a snapshot of change-logged models
+                            if hasattr(obj, 'snapshot'):
+                                obj.snapshot()
+                            obj.delete()
+
+                except (ProtectedError, RestrictedError) as e:
+                    logger.info(f"Caught {type(e)} while attempting to delete objects")
                     handle_protectederror(queryset, request, e)
                     return redirect(self.get_return_url(request))
 

+ 3 - 3
netbox/netbox/views/generic/object_views.py

@@ -3,7 +3,7 @@ from copy import deepcopy
 
 from django.contrib import messages
 from django.db import transaction
-from django.db.models import ProtectedError
+from django.db.models import ProtectedError, RestrictedError
 from django.shortcuts import redirect, render
 from django.urls import reverse
 from django.utils.html import escape
@@ -374,8 +374,8 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
             try:
                 obj.delete()
 
-            except ProtectedError as e:
-                logger.info("Caught ProtectedError while attempting to delete object")
+            except (ProtectedError, RestrictedError) as e:
+                logger.info(f"Caught {type(e)} while attempting to delete objects")
                 handle_protectederror([obj], request, e)
                 return redirect(obj.get_absolute_url())
 

+ 15 - 5
netbox/utilities/error_handlers.py

@@ -1,16 +1,26 @@
 from django.contrib import messages
+from django.db.models import ProtectedError, RestrictedError
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
 
 
 def handle_protectederror(obj_list, request, e):
     """
-    Generate a user-friendly error message in response to a ProtectedError exception.
+    Generate a user-friendly error message in response to a ProtectedError or RestrictedError exception.
     """
-    protected_objects = list(e.protected_objects)
-    protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50'
-    err_message = f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. " \
-                  f"{protected_count} dependent objects were found: "
+    if type(e) is ProtectedError:
+        protected_objects = list(e.protected_objects)
+    elif type(e) is RestrictedError:
+        protected_objects = list(e.restricted_objects)
+    else:
+        raise e
+
+    # Formulate the error message
+    err_message = _("Unable to delete <strong>{objects}</strong>. {count} dependent objects were found: ").format(
+        objects=', '.join(str(obj) for obj in obj_list),
+        count=len(protected_objects) if len(protected_objects) <= 50 else _('More than 50')
+    )
 
     # Append dependent objects to error message
     dependent_objects = []

+ 5 - 0
netbox/virtualization/api/views.py

@@ -3,6 +3,7 @@ from rest_framework.routers import APIRootView
 from dcim.models import Device
 from extras.api.mixins import ConfigContextQuerySetMixin
 from netbox.api.viewsets import NetBoxModelViewSet
+from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from virtualization import filtersets
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -87,3 +88,7 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
     serializer_class = serializers.VMInterfaceSerializer
     filterset_class = filtersets.VMInterfaceFilterSet
     brief_prefetch_fields = ['virtual_machine']
+
+    def get_bulk_destroy_queryset(self):
+        # Ensure child interfaces are deleted prior to their parents
+        return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name'))

+ 19 - 0
netbox/virtualization/migrations/0037_protect_child_interfaces.py

@@ -0,0 +1,19 @@
+# Generated by Django 4.2.6 on 2023-10-20 11:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0036_virtualmachine_config_template'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vminterface',
+            name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='virtualization.vminterface'),
+        ),
+    ]

+ 26 - 0
netbox/virtualization/tests/test_api.py

@@ -293,3 +293,29 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
                 'vrf': vrfs[2].pk,
             },
         ]
+
+    def test_bulk_delete_child_interfaces(self):
+        interface1 = VMInterface.objects.get(name='Interface 1')
+        virtual_machine = interface1.virtual_machine
+        self.add_permissions('virtualization.delete_vminterface')
+
+        # Create a child interface
+        child = VMInterface.objects.create(
+            virtual_machine=virtual_machine,
+            name='Interface 1A',
+            parent=interface1
+        )
+        self.assertEqual(virtual_machine.interfaces.count(), 4)
+
+        # Attempt to delete only the parent interface
+        url = self._get_detail_url(interface1)
+        self.client.delete(url, **self.header)
+        self.assertEqual(virtual_machine.interfaces.count(), 4)  # Parent was not deleted
+
+        # Attempt to bulk delete parent & child together
+        data = [
+            {"id": interface1.pk},
+            {"id": child.pk},
+        ]
+        self.client.delete(self._get_list_url(), data, format='json', **self.header)
+        self.assertEqual(virtual_machine.interfaces.count(), 2)  # Child & parent were both deleted

+ 29 - 0
netbox/virtualization/tests/test_views.py

@@ -374,3 +374,32 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
         }
+
+    def test_bulk_delete_child_interfaces(self):
+        interface1 = VMInterface.objects.get(name='Interface 1')
+        virtual_machine = interface1.virtual_machine
+        self.add_permissions('virtualization.delete_vminterface')
+
+        # Create a child interface
+        child = VMInterface.objects.create(
+            virtual_machine=virtual_machine,
+            name='Interface 1A',
+            parent=interface1
+        )
+        self.assertEqual(virtual_machine.interfaces.count(), 4)
+
+        # Attempt to delete only the parent interface
+        data = {
+            'confirm': True,
+        }
+        self.client.post(self._get_url('delete', interface1), data)
+        self.assertEqual(virtual_machine.interfaces.count(), 4)  # Parent was not deleted
+
+        # Attempt to bulk delete parent & child together
+        data = {
+            'pk': [interface1.pk, child.pk],
+            'confirm': True,
+            '_confirm': True,  # Form button
+        }
+        self.client.post(self._get_url('bulk_delete'), data)
+        self.assertEqual(virtual_machine.interfaces.count(), 2)  # Child & parent were both deleted

+ 3 - 2
netbox/virtualization/views.py

@@ -1,5 +1,4 @@
 import traceback
-from collections import defaultdict
 
 from django.contrib import messages
 from django.db import transaction
@@ -19,6 +18,7 @@ from ipam.tables import InterfaceVLANTable
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
+from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from . import filtersets, forms, tables
@@ -550,7 +550,8 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
 
 
 class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
-    queryset = VMInterface.objects.all()
+    # Ensure child interfaces are deleted prior to their parents
+    queryset = VMInterface.objects.order_by('virtual_machine', 'parent', CollateAsChar('_name'))
     filterset = filtersets.VMInterfaceFilterSet
     table = tables.VMInterfaceTable