mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 16:06:43 -06:00
Merge branch 'feature' into 14132-event-refactor-2
This commit is contained in:
commit
4f3b086f3f
@ -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.
|
||||||
|
@ -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.
|
||||||
|
@ -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.
|
||||||
|
@ -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'
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
|
18
netbox/core/migrations/0007_job_add_error_field.py
Normal file
18
netbox/core/migrations/0007_job_add_error_field.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
29
netbox/core/migrations/0008_contenttype_proxy.py
Normal file
29
netbox/core/migrations/0008_contenttype_proxy.py
Normal 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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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 *
|
||||||
|
32
netbox/core/models/contenttypes.py
Normal file
32
netbox/core/models/contenttypes.py
Normal 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
|
@ -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(
|
||||||
|
@ -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')
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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(
|
||||||
|
19
netbox/dcim/migrations/0183_protect_child_interfaces.py
Normal file
19
netbox/dcim/migrations/0183_protect_child_interfaces.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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')
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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')
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)")
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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)")
|
||||||
)
|
)
|
||||||
|
@ -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}")
|
||||||
|
|
||||||
|
17
netbox/extras/migrations/0099_cachedvalue_ordering.py
Normal file
17
netbox/extras/migrations/0099_cachedvalue_ordering.py
Normal 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')},
|
||||||
|
),
|
||||||
|
]
|
@ -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:
|
||||||
model = apps.get_registered_model(app, model_name)
|
try:
|
||||||
_context[app][model.__name__] = model
|
model = apps.get_registered_model(app, model_name)
|
||||||
|
_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:
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
@ -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()
|
||||||
|
@ -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}")
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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:
|
||||||
protected_objects = list(e.protected_objects)
|
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 = 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)
|
||||||
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -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:
|
||||||
for obj in queryset:
|
with transaction.atomic():
|
||||||
# Take a snapshot of change-logged models
|
for obj in queryset:
|
||||||
if hasattr(obj, 'snapshot'):
|
# Take a snapshot of change-logged models
|
||||||
obj.snapshot()
|
if hasattr(obj, 'snapshot'):
|
||||||
obj.delete()
|
obj.snapshot()
|
||||||
|
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))
|
||||||
|
|
||||||
|
@ -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())
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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')
|
||||||
|
@ -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.
|
||||||
"""
|
"""
|
||||||
protected_objects = list(e.protected_objects)
|
if type(e) is ProtectedError:
|
||||||
protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50'
|
protected_objects = list(e.protected_objects)
|
||||||
err_message = f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. " \
|
elif type(e) is RestrictedError:
|
||||||
f"{protected_count} dependent objects were found: "
|
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
|
# Append dependent objects to error message
|
||||||
dependent_objects = []
|
dependent_objects = []
|
||||||
|
@ -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'))
|
||||||
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user