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
This commit is contained in:
Jeremy Stretch 2023-11-01 13:47:14 -04:00 committed by GitHub
parent c2d1988cb3
commit 944008d475
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 214 additions and 26 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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(

View File

@ -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'),
),
]

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:
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)

View File

@ -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]
)

View File

@ -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:
with transaction.atomic():
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")
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))

View File

@ -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())

View File

@ -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.
"""
if type(e) is ProtectedError:
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: "
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 = []

View File

@ -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'))

View File

@ -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'),
),
]

View File

@ -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

View File

@ -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

View File

@ -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