From c20e6dd2ee562888261330c99266165e36ad0664 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 7 May 2026 11:23:00 -0500 Subject: [PATCH] Closes #20776: Add changelog message to bulk rename process (#22100) Add changelog message support to BulkRenameView for models that support change logging. Introduce NetBoxModelBulkRenameForm as a changelog-aware wrapper around the existing utilities.forms.BulkRenameForm, preserving the existing import path while avoiding circular imports. Set _changelog_message before saving renamed objects in both MPTT and non-MPTT rename paths, and add a regression test to verify that the submitted message is recorded on the resulting ObjectChange records. Remove unused VMInterfaceBulkRenameForm and VirtualDiskBulkRenameForm, along with their unused view references, since BulkRenameView constructs its form dynamically. --- netbox/netbox/forms/__init__.py | 1 + netbox/netbox/forms/bulk_rename.py | 14 +++++++++ netbox/netbox/views/generic/bulk_views.py | 13 +++++++-- netbox/utilities/testing/views.py | 35 +++++++++++++++++++++++ netbox/virtualization/forms/bulk_edit.py | 18 +----------- netbox/virtualization/views.py | 2 -- 6 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 netbox/netbox/forms/bulk_rename.py diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index fe5e5f7d3..e61824035 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -1,5 +1,6 @@ from .bulk_edit import * from .bulk_import import * +from .bulk_rename import * from .filtersets import * from .model_forms import * from .search import * diff --git a/netbox/netbox/forms/bulk_rename.py b/netbox/netbox/forms/bulk_rename.py new file mode 100644 index 000000000..975466f6a --- /dev/null +++ b/netbox/netbox/forms/bulk_rename.py @@ -0,0 +1,14 @@ +from utilities.forms import BulkRenameForm + +from .mixins import ChangelogMessageMixin + +__all__ = ( + 'NetBoxModelBulkRenameForm', +) + + +class NetBoxModelBulkRenameForm(ChangelogMessageMixin, BulkRenameForm): + """ + Extends BulkRenameForm with a changelog message field for NetBox models that support change logging. + """ + pass diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 8f0a98b50..41eeaba88 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -22,6 +22,7 @@ from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate +from netbox.forms.bulk_rename import NetBoxModelBulkRenameForm from netbox.models.features import ChangeLoggingMixin from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror @@ -881,8 +882,14 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Create a new Form class from BulkRenameForm - class _Form(BulkRenameForm): + # Use the changelog-aware form for models that support change logging + base_form = ( + NetBoxModelBulkRenameForm + if issubclass(self.queryset.model, ChangeLoggingMixin) + else BulkRenameForm + ) + + class _Form(base_form): pk = ModelMultipleChoiceField( queryset=self.queryset, widget=MultipleHiddenInput() @@ -940,10 +947,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): with self.queryset.model.objects.delay_mptt_updates(): for obj in selected_objects: setattr(obj, self.field_name, obj.new_name) + obj._changelog_message = form.cleaned_data.get('changelog_message', '') obj.save() else: for obj in selected_objects: setattr(obj, self.field_name, obj.new_name) + obj._changelog_message = form.cleaned_data.get('changelog_message', '') obj.save() # Enforce constrained permissions diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 6abea2f98..38edc8aa9 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1049,6 +1049,41 @@ class ViewTestCases: for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): self.assertEqual(instance.name, f'{objects[i].name}X') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_rename_objects_with_changelog_message(self): + if not issubclass(self.model, ChangeLoggingMixin): + self.skipTest("Model does not support change logging") + objects = self._get_queryset().all()[:3] + pk_list = [obj.pk for obj in objects] + data = { + 'pk': pk_list, + '_apply': True, + 'changelog_message': 'Bulk rename test message', + } + data.update(self.rename_data) + + # Assign model-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302) + + # Verify changelog message was recorded on each renamed object + object_type = ObjectType.objects.get_for_model(self.model) + for pk in pk_list: + oc = ObjectChange.objects.filter( + changed_object_type=object_type, + changed_object_id=pk, + action=ObjectChangeActionChoices.ACTION_UPDATE, + ).order_by('-time').first() + self.assertIsNotNone(oc) + self.assertEqual(oc.message, 'Bulk rename test message') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_rename_objects_with_constrained_permission(self): objects = self._get_queryset().all()[:3] diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index f51e51e60..6f0feaf0e 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -11,7 +11,7 @@ from ipam.models import VLAN, VRF, VLANGroup, VLANTranslationPolicy from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm from netbox.forms.mixins import OwnerMixin from tenancy.models import Tenant -from utilities.forms import BulkRenameForm, add_blank_choice +from utilities.forms import add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.utils import get_capacity_unit_label @@ -25,9 +25,7 @@ __all__ = ( 'ClusterGroupBulkEditForm', 'ClusterTypeBulkEditForm', 'VMInterfaceBulkEditForm', - 'VMInterfaceBulkRenameForm', 'VirtualDiskBulkEditForm', - 'VirtualDiskBulkRenameForm', 'VirtualMachineBulkEditForm', 'VirtualMachineTypeBulkEditForm', ) @@ -343,13 +341,6 @@ class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): self.fields['bridge'].widget.attrs['disabled'] = True -class VMInterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=VMInterface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): virtual_machine = forms.ModelChoiceField( label=_('Virtual machine'), @@ -379,10 +370,3 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): # Set unit label based on configured DISK_BASE_UNIT (MB vs MiB) self.fields['size'].label = _('Size ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT)) - - -class VirtualDiskBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualDisk.objects.all(), - widget=forms.MultipleHiddenInput() - ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index d6a758dad..a90a20a73 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -744,7 +744,6 @@ class VMInterfaceBulkEditView(generic.BulkEditView): class VMInterfaceBulkRenameView(generic.BulkRenameView): queryset = VMInterface.objects.all() filterset = filtersets.VMInterfaceFilterSet - form = forms.VMInterfaceBulkRenameForm @register_model_view(VMInterface, 'bulk_delete', path='delete', detail=False) @@ -817,7 +816,6 @@ class VirtualDiskBulkEditView(generic.BulkEditView): class VirtualDiskBulkRenameView(generic.BulkRenameView): queryset = VirtualDisk.objects.all() filterset = filtersets.VirtualDiskFilterSet - form = forms.VirtualDiskBulkRenameForm @register_model_view(VirtualDisk, 'bulk_delete', path='delete', detail=False)