Merge branch 'feature' into 14132-event-refactor-2

This commit is contained in:
Arthur 2023-11-08 16:06:03 -08:00
commit 4f3b086f3f
47 changed files with 431 additions and 59 deletions

View File

@ -41,6 +41,10 @@ A dictionary of particular features (e.g. custom fields) mapped to the NetBox mo
Supported model features are listed in the [features matrix](./models.md#features-matrix). Supported model features are listed in the [features matrix](./models.md#features-matrix).
### `models`
This key lists all models which have been registered in NetBox which are not designated for private use. (Setting `_netbox_private` to True on a model excludes it from this list.) As with individual features under `model_features`, models are organized by app label.
### `plugins` ### `plugins`
This store maintains all registered items for plugins, such as navigation menus, template extensions, etc. This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.

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. 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 ### Bridged Interface
Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped. 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). 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 ### Bridged Interface
An interface on the same VM with which this interface is bridged. An interface on the same VM with which this interface is bridged.

View File

@ -7,6 +7,8 @@ class UserToken(Token):
""" """
Proxy model for users to manage their own API tokens. Proxy model for users to manage their own API tokens.
""" """
_netbox_private = True
class Meta: class Meta:
proxy = True proxy = True
verbose_name = 'token' verbose_name = 'token'

View File

@ -69,5 +69,5 @@ class JobSerializer(BaseModelSerializer):
model = Job model = Job
fields = [ fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', 'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'started', 'completed', 'user', 'data', 'job_id', 'started', 'completed', 'user', 'data', 'error', 'job_id',
] ]

View File

@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs):
job.terminate() job.terminate()
except Exception as e: except Exception as e:
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) in (SyncError, JobTimeoutException): if type(e) in (SyncError, JobTimeoutException):
logging.error(e) logging.error(e)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.6 on 2023-10-23 20:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_datasource_type_remove_choices'),
]
operations = [
migrations.AddField(
model_name='job',
name='error',
field=models.TextField(blank=True, editable=False),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.6 on 2023-10-31 19:38
import core.models.contenttypes
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0007_job_add_error_field'),
]
operations = [
migrations.CreateModel(
name='ContentType',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('contenttypes.contenttype',),
managers=[
('objects', core.models.contenttypes.ContentTypeManager()),
],
),
]

View File

@ -1,3 +1,4 @@
from .contenttypes import *
from .data import * from .data import *
from .files import * from .files import *
from .jobs import * from .jobs import *

View File

@ -0,0 +1,32 @@
from django.contrib.contenttypes.models import ContentType as ContentType_, ContentTypeManager as ContentTypeManager_
from django.db.models import Q
from netbox.registry import registry
__all__ = (
'ContentType',
'ContentTypeManager',
)
class ContentTypeManager(ContentTypeManager_):
def public(self):
"""
Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed
in registry['models'] and intended for reference by other objects.
"""
q = Q()
for app_label, models in registry['models'].items():
q |= Q(app_label=app_label, model__in=models)
return self.get_queryset().filter(q)
class ContentType(ContentType_):
"""
Wrap Django's native ContentType model to use our custom manager.
"""
objects = ContentTypeManager()
class Meta:
proxy = True

View File

@ -378,6 +378,8 @@ class AutoSyncRecord(models.Model):
fk_field='object_id' fk_field='object_id'
) )
_netbox_private = True
class Meta: class Meta:
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(

View File

@ -44,6 +44,7 @@ class ManagedFile(SyncedDataMixin, models.Model):
) )
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
_netbox_private = True
class Meta: class Meta:
ordering = ('file_root', 'file_path') ordering = ('file_root', 'file_path')

View File

@ -92,6 +92,11 @@ class Job(models.Model):
null=True, null=True,
blank=True blank=True
) )
error = models.TextField(
verbose_name=_('error'),
editable=False,
blank=True
)
job_id = models.UUIDField( job_id = models.UUIDField(
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
@ -158,7 +163,7 @@ class Job(models.Model):
# Handle events # Handle events
self.trigger_events(event=EVENT_JOB_START) self.trigger_events(event=EVENT_JOB_START)
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED): def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
""" """
Mark the job as completed, optionally specifying a particular termination status. Mark the job as completed, optionally specifying a particular termination status.
""" """
@ -168,6 +173,8 @@ class Job(models.Model):
# Mark the job as completed # Mark the job as completed
self.status = status self.status = status
if error:
self.error = error
self.completed = timezone.now() self.completed = timezone.now()
self.save() self.save()

View File

@ -47,7 +47,7 @@ class JobTable(NetBoxTable):
model = Job model = Job
fields = ( fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'job_id', 'completed', 'user', 'error', 'job_id',
) )
default_columns = ( default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',

View File

@ -24,6 +24,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import serializers from . import serializers
@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
brief_prefetch_fields = ['device'] 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): class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related( 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

@ -431,6 +431,8 @@ class CablePath(models.Model):
) )
_nodes = PathField() _nodes = PathField()
_netbox_private = True
class Meta: class Meta:
verbose_name = _('cable path') verbose_name = _('cable path')
verbose_name_plural = _('cable paths') verbose_name_plural = _('cable paths')

View File

@ -537,7 +537,7 @@ class BaseInterface(models.Model):
) )
parent = models.ForeignKey( parent = models.ForeignKey(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.RESTRICT,
related_name='child_interfaces', related_name='child_interfaces',
null=True, null=True,
blank=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): class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort

View File

@ -2531,6 +2531,36 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
self.assertHttpStatus(response, 200) 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): class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort model = FrontPort

View File

@ -1,5 +1,4 @@
import traceback import traceback
from collections import defaultdict
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -26,6 +25,7 @@ from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -2562,7 +2562,8 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
class InterfaceBulkDeleteView(generic.BulkDeleteView): 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 filterset = filtersets.InterfaceFilterSet
table = tables.InterfaceTable table = tables.InterfaceTable

View File

@ -7,15 +7,13 @@ import feedparser
import requests import requests
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Q
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.models import ContentType
from extras.choices import BookmarkOrderingChoices from extras.choices import BookmarkOrderingChoices
from extras.utils import FeatureQuery
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
@ -37,10 +35,7 @@ __all__ = (
def get_content_type_labels(): def get_content_type_labels():
return [ return [
(content_type_identifier(ct), content_type_name(ct)) (content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.filter( for ct in ContentType.objects.public().order_by('app_label', 'model')
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
Q(app_label='extras', model='configcontext')
).order_by('app_label', 'model')
] ]

View File

@ -1,9 +1,9 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
@ -41,8 +41,7 @@ class CustomFieldImportForm(CSVModelForm):
) )
object_type = CSVContentTypeField( object_type = CSVContentTypeField(
label=_('Object type'), label=_('Object type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.public(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False, required=False,
help_text=_("Object type (for object or multi-object fields)") help_text=_("Object type (for object or multi-object fields)")
) )

View File

@ -1,9 +1,8 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import DataFile, DataSource from core.models import ContentType, DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
@ -197,7 +196,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.public(),
required=False required=False
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(

View File

@ -2,12 +2,11 @@ import json
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from core.models import ContentType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
@ -50,9 +49,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
) )
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object type'), label=_('Object type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.public(),
# TODO: Come up with a canonical way to register suitable models
limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
required=False, required=False,
help_text=_("Type of the related object (for object/multi-object fields only)") help_text=_("Type of the related object (for object/multi-object fields only)")
) )

View File

@ -59,7 +59,7 @@ class Command(BaseCommand):
logger.error(f"Exception raised during script execution: {e}") logger.error(f"Exception raised during script execution: {e}")
clear_webhooks.send(request) clear_webhooks.send(request)
job.data = ScriptOutputSerializer(script).data job.data = ScriptOutputSerializer(script).data
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
logger.info(f"Script completed in {job.duration}") logger.info(f"Script completed in {job.duration}")

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.6 on 2023-10-30 14:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0098_webhook_custom_field_data_webhook_tags'),
]
operations = [
migrations.AlterModelOptions(
name='cachedvalue',
options={'ordering': ('weight', 'object_type', 'value', 'object_id')},
),
]

View File

@ -260,12 +260,14 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
_context = dict() _context = dict()
# Populate the default template context with NetBox model classes, namespaced by app # Populate the default template context with NetBox model classes, namespaced by app
# TODO: Devise a canonical mechanism for identifying the models to include (see #13427) for app, model_names in registry['models'].items():
for app, model_names in registry['model_features']['custom_fields'].items():
_context.setdefault(app, {}) _context.setdefault(app, {})
for model_name in model_names: for model_name in model_names:
try:
model = apps.get_registered_model(app, model_name) model = apps.get_registered_model(app, model_name)
_context[app][model.__name__] = model _context[app][model.__name__] = model
except LookupError:
pass
# Add the provided context data, if any # Add the provided context data, if any
if context is not None: if context is not None:

View File

@ -49,8 +49,10 @@ class CachedValue(models.Model):
default=1000 default=1000
) )
_netbox_private = True
class Meta: class Meta:
ordering = ('weight', 'object_type', 'object_id') ordering = ('weight', 'object_type', 'value', 'object_id')
verbose_name = _('cached value') verbose_name = _('cached value')
verbose_name_plural = _('cached values') verbose_name_plural = _('cached values')

View File

@ -75,6 +75,8 @@ class TaggedItem(GenericTaggedItemBase):
on_delete=models.CASCADE on_delete=models.CASCADE
) )
_netbox_private = True
class Meta: class Meta:
indexes = [models.Index(fields=["content_type", "object_id"])] indexes = [models.Index(fields=["content_type", "object_id"])]
verbose_name = _('tagged item') verbose_name = _('tagged item')

View File

@ -40,8 +40,8 @@ def run_report(job, *args, **kwargs):
try: try:
report.run(job) report.run(job)
except Exception: except Exception as e:
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
logging.error(f"Error during execution of report {job.name}") logging.error(f"Error during execution of report {job.name}")
finally: finally:
# Schedule the next job if an interval has been set # Schedule the next job if an interval has been set
@ -230,7 +230,7 @@ class Report(object):
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>") self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
logger.error(f"Exception raised during report execution: {e}") logger.error(f"Exception raised during report execution: {e}")
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
# Perform any post-run tasks # Perform any post-run tasks
self.post_run() self.post_run()

View File

@ -519,7 +519,7 @@ def run_script(data, request, job, commit=True, **kwargs):
logger.error(f"Exception raised during script execution: {e}") logger.error(f"Exception raised during script execution: {e}")
script.log_info("Database changes have been reverted due to error.") script.log_info("Database changes have been reverted due to error.")
job.data = ScriptOutputSerializer(script).data job.data = ScriptOutputSerializer(script).data
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
clear_webhooks.send(request) clear_webhooks.send(request)
logger.info(f"Script completed in {job.duration}") logger.info(f"Script completed in {job.duration}")

View File

@ -67,6 +67,10 @@ def register_features(model, features):
f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}" f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}"
) )
# Register public models
if not getattr(model, '_netbox_private', False):
registry['models'][app_label].add(model_name)
def is_script(obj): def is_script(obj):
""" """

View File

@ -2,7 +2,7 @@ import logging
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction 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 django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins from rest_framework import mixins as drf_mixins
@ -91,8 +91,11 @@ class NetBoxModelViewSet(
try: try:
return super().dispatch(request, *args, **kwargs) 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) 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 = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
logger.warning(msg) 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): def bulk_update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False) partial = kwargs.pop('partial', False)
serializer = BulkOperationSerializer(data=request.data, many=True) serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=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] pk__in=[o['id'] for o in serializer.data]
) )
@ -184,10 +187,13 @@ class BulkDestroyModelMixin:
{"id": 456} {"id": 456}
] ]
""" """
def get_bulk_destroy_queryset(self):
return self.get_queryset()
def bulk_destroy(self, request, *args, **kwargs): def bulk_destroy(self, request, *args, **kwargs):
serializer = BulkOperationSerializer(data=request.data, many=True) serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=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] pk__in=[o['id'] for o in serializer.data]
) )

View File

@ -25,6 +25,7 @@ registry = Registry({
'data_backends': dict(), 'data_backends': dict(),
'denormalized_fields': collections.defaultdict(list), 'denormalized_fields': collections.defaultdict(list),
'model_features': dict(), 'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(), 'plugins': dict(),
'search': dict(), 'search': dict(),
'views': collections.defaultdict(dict), 'views': collections.defaultdict(dict),

View File

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

View File

@ -1,9 +1,11 @@
import logging import logging
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import router, transaction
from django.db.models import ProtectedError from django.db.models import ProtectedError, RestrictedError
from django.db.models.deletion import Collector
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
@ -320,6 +322,27 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'delete') return get_permission_for_model(self.queryset.model, 'delete')
def _get_dependent_objects(self, obj):
"""
Returns a dictionary mapping of dependent objects (organized by model) which will be deleted as a result of
deleting the requested object.
Args:
obj: The object to return dependent objects for
"""
using = router.db_for_write(obj._meta.model)
collector = Collector(using=using)
collector.collect([obj])
# Compile a mapping of models to instances
dependent_objects = defaultdict(list)
for model, instance in collector.instances_with_model():
# Omit the root object
if instance != obj:
dependent_objects[model].append(instance)
return dict(dependent_objects)
# #
# Request handlers # Request handlers
# #
@ -333,6 +356,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
""" """
obj = self.get_object(**kwargs) obj = self.get_object(**kwargs)
form = ConfirmationForm(initial=request.GET) form = ConfirmationForm(initial=request.GET)
dependent_objects = self._get_dependent_objects(obj)
# If this is an HTMX request, return only the rendered deletion form as modal content # If this is an HTMX request, return only the rendered deletion form as modal content
if is_htmx(request): if is_htmx(request):
@ -343,6 +367,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
'object_type': self.queryset.model._meta.verbose_name, 'object_type': self.queryset.model._meta.verbose_name,
'form': form, 'form': form,
'form_url': form_url, 'form_url': form_url,
'dependent_objects': dependent_objects,
**self.get_extra_context(request, obj), **self.get_extra_context(request, obj),
}) })
@ -350,6 +375,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
'object': obj, 'object': obj,
'form': form, 'form': form,
'return_url': self.get_return_url(request, obj), 'return_url': self.get_return_url(request, obj),
'dependent_objects': dependent_objects,
**self.get_extra_context(request, obj), **self.get_extra_context(request, obj),
}) })
@ -374,8 +400,8 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
try: try:
obj.delete() obj.delete()
except ProtectedError as e: except (ProtectedError, RestrictedError) as e:
logger.info("Caught ProtectedError while attempting to delete object") logger.info(f"Caught {type(e)} while attempting to delete objects")
handle_protectederror([obj], request, e) handle_protectederror([obj], request, e)
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())

View File

@ -35,6 +35,12 @@
<th scope="row">{% trans "Status" %}</th> <th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display object.get_status_color %}</td> <td>{% badge object.get_status_display object.get_status_color %}</td>
</tr> </tr>
{% if object.error %}
<tr>
<th scope="row">{% trans "Error" %}</th>
<td>{{ object.error }}</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">{% trans "Created By" %}</th> <th scope="row">{% trans "Created By" %}</th>
<td>{{ object.user|placeholder }}</td> <td>{{ object.user|placeholder }}</td>

View File

@ -12,6 +12,40 @@
Are you sure you want to <strong class="text-danger">delete</strong> {{ object_type }} <strong>{{ object }}</strong>? Are you sure you want to <strong class="text-danger">delete</strong> {{ object_type }} <strong>{{ object }}</strong>?
{% endblocktrans %} {% endblocktrans %}
</p> </p>
{% if dependent_objects %}
<p>
{% trans "The following objects will be deleted as a result of this action." %}
</p>
<div class="accordion" id="deleteAccordion">
{% for model, instances in dependent_objects.items %}
<div class="accordion-item">
<h2 class="accordion-header" id="deleteheading{{ forloop.counter }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="false" aria-controls="collapse{{ forloop.counter }}">
{% with object_count=instances|length %}
{{ object_count }}
{% if object_count == 1 %}
{{ model|meta:"verbose_name" }}
{% else %}
{{ model|meta:"verbose_name_plural" }}
{% endif %}
{% endwith %}
</button>
</h2>
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse" aria-labelledby="deleteheading{{ forloop.counter }}" data-bs-parent="#deleteAccordion">
<div class="accordion-body p-0">
<div class="list-group list-group-flush">
{% for instance in instances %}
{% with url=instance.get_absolute_url %}
<a {% if url %}href="{{ url }}" {% endif %}class="list-group-item list-group-item-action">{{ instance }}</a>
{% endwith %}
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% render_form form %} {% render_form form %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -99,6 +99,8 @@ class UserConfig(models.Model):
default=dict default=dict
) )
_netbox_private = True
class Meta: class Meta:
ordering = ['user'] ordering = ['user']
verbose_name = _('user preferences') verbose_name = _('user preferences')

View File

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

View File

@ -3,6 +3,7 @@ from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.mixins import ConfigContextQuerySetMixin from extras.api.mixins import ConfigContextQuerySetMixin
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization import filtersets from virtualization import filtersets
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -87,3 +88,7 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
serializer_class = serializers.VMInterfaceSerializer serializer_class = serializers.VMInterfaceSerializer
filterset_class = filtersets.VMInterfaceFilterSet filterset_class = filtersets.VMInterfaceFilterSet
brief_prefetch_fields = ['virtual_machine'] 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, '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, 'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]], '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 import traceback
from collections import defaultdict
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
@ -19,6 +18,7 @@ from ipam.tables import InterfaceVLANTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView from tenancy.views import ObjectContactsView
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -550,7 +550,8 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
class VMInterfaceBulkDeleteView(generic.BulkDeleteView): 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 filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable table = tables.VMInterfaceTable