From 063bad07cb08ab46b8b746bd75492cf9db49c355 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 30 Apr 2026 10:30:35 -0500 Subject: [PATCH 1/4] Fixes #20776: Add changelog message to bulk rename process Move BulkRenameForm to netbox.forms.bulk_rename to enable ChangelogMessageMixin inheritance without circular imports. Wire up _changelog_message on objects before save in BulkRenameView, with conditional field removal for models that don't support change logging. --- netbox/netbox/forms/__init__.py | 1 + netbox/netbox/forms/bulk_rename.py | 40 +++++++++++++++++++++++ netbox/netbox/views/generic/bulk_views.py | 9 ++++- netbox/utilities/forms/forms.py | 33 ------------------- netbox/utilities/testing/views.py | 33 +++++++++++++++++++ netbox/utilities/tests/test_forms.py | 2 +- netbox/virtualization/forms/bulk_edit.py | 3 +- 7 files changed, 85 insertions(+), 36 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 fe5e5f7d389..e61824035a6 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 00000000000..4eba5c81569 --- /dev/null +++ b/netbox/netbox/forms/bulk_rename.py @@ -0,0 +1,40 @@ +import re + +from django import forms +from django.utils.translation import gettext as _ + +from .mixins import ChangelogMessageMixin + +__all__ = ( + 'BulkRenameForm', +) + + +class BulkRenameForm(ChangelogMessageMixin, forms.Form): + """ + An extendable form to be used for renaming objects in bulk. + """ + find = forms.CharField( + strip=False + ) + replace = forms.CharField( + strip=False, + required=False + ) + use_regex = forms.BooleanField( + required=False, + initial=True, + label=_('Use regular expressions') + ) + + def clean(self): + super().clean() + + # Validate regular expression in "find" field + if self.cleaned_data['use_regex']: + try: + re.compile(self.cleaned_data['find']) + except re.error: + raise forms.ValidationError({ + 'find': "Invalid regular expression" + }) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 8f0a98b5054..2183041cf29 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -22,12 +22,13 @@ from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate +from netbox.forms.bulk_rename import BulkRenameForm from netbox.models.features import ChangeLoggingMixin from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.export import TableExport, stream_table_csv_response -from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields +from utilities.forms import BulkDeleteForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial from utilities.jobs import is_background_request, process_request_as_job @@ -890,6 +891,10 @@ class _Form(BulkRenameForm): self.form = _Form + # Remove changelog_message field if model doesn't support change logging + if not issubclass(self.queryset.model, ChangeLoggingMixin): + self.form.base_fields.pop('changelog_message', None) + def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') @@ -940,10 +945,12 @@ def post(self, request): 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/forms/forms.py b/netbox/utilities/forms/forms.py index b2dee5721a4..554e9defe7d 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -1,5 +1,3 @@ -import re - from django import forms from django.utils.translation import gettext as _ @@ -10,7 +8,6 @@ __all__ = ( 'BulkDeleteForm', 'BulkEditForm', - 'BulkRenameForm', 'CSVModelForm', 'ConfirmationForm', 'DeleteForm', @@ -61,36 +58,6 @@ class BulkEditForm(BackgroundJobMixin, forms.Form): nullable_fields = () -class BulkRenameForm(forms.Form): - """ - An extendable form to be used for renaming objects in bulk. - """ - find = forms.CharField( - strip=False - ) - replace = forms.CharField( - strip=False, - required=False - ) - use_regex = forms.BooleanField( - required=False, - initial=True, - label=_('Use regular expressions') - ) - - def clean(self): - super().clean() - - # Validate regular expression in "find" field - if self.cleaned_data['use_regex']: - try: - re.compile(self.cleaned_data['find']) - except re.error: - raise forms.ValidationError({ - 'find': "Invalid regular expression" - }) - - class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=None, diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 6abea2f984a..3f76757c7d0 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1049,6 +1049,39 @@ def test_bulk_rename_objects_with_permission(self): 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): + 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/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index b29cbadf6a1..29314839876 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -3,9 +3,9 @@ from dcim.models import Site from netbox.choices import ImportFormatChoices +from netbox.forms.bulk_rename import BulkRenameForm from utilities.forms.bulk_import import BulkImportForm from utilities.forms.fields.csv import CSVSelectWidget -from utilities.forms.forms import BulkRenameForm from utilities.forms.utils import ( expand_alphanumeric_pattern, expand_ipnetwork_pattern, diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index f51e51e6041..2b6725323d8 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -9,9 +9,10 @@ from extras.models import ConfigTemplate from ipam.models import VLAN, VRF, VLANGroup, VLANTranslationPolicy from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm +from netbox.forms.bulk_rename import BulkRenameForm 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 From 5e2dbde0fb453ae49f28c21a28787d6b3ac36cf5 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 5 May 2026 12:58:18 -0500 Subject: [PATCH 2/4] Fixes #20776: Address review feedback on bulk rename changelog - Add explanatory comment on base_fields.pop() safety - Add skipTest guard for models without ChangeLoggingMixin --- netbox/netbox/views/generic/bulk_views.py | 3 ++- netbox/utilities/testing/views.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 2183041cf29..a6f6ec738a1 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -891,7 +891,8 @@ class _Form(BulkRenameForm): self.form = _Form - # Remove changelog_message field if model doesn't support change logging + # Remove changelog_message field if model doesn't support change logging. + # Mutating base_fields is safe here because _Form is created fresh per request above. if not issubclass(self.queryset.model, ChangeLoggingMixin): self.form.base_fields.pop('changelog_message', None) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 3f76757c7d0..38edc8aa91b 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1051,6 +1051,8 @@ def test_bulk_rename_objects_with_permission(self): @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 = { From 92181faa432997f75687c4a613f8aae3bb6963b9 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 7 May 2026 10:50:16 -0500 Subject: [PATCH 3/4] Fixes #20776: Address PR review feedback Restore BulkRenameForm to utilities.forms (preserving the existing import path) and introduce NetBoxModelBulkRenameForm in netbox.forms as a changelog-aware wrapper, following the established NetBoxModelBulkEditForm pattern. BulkRenameView now conditionally selects the base form based on whether the model supports change logging, eliminating the need to mutate base_fields after class creation. --- netbox/netbox/forms/bulk_rename.py | 36 ++++------------------- netbox/netbox/views/generic/bulk_views.py | 19 ++++++------ netbox/utilities/forms/forms.py | 33 +++++++++++++++++++++ netbox/utilities/tests/test_forms.py | 2 +- netbox/virtualization/forms/bulk_edit.py | 3 +- 5 files changed, 50 insertions(+), 43 deletions(-) diff --git a/netbox/netbox/forms/bulk_rename.py b/netbox/netbox/forms/bulk_rename.py index 4eba5c81569..975466f6a83 100644 --- a/netbox/netbox/forms/bulk_rename.py +++ b/netbox/netbox/forms/bulk_rename.py @@ -1,40 +1,14 @@ -import re - -from django import forms -from django.utils.translation import gettext as _ +from utilities.forms import BulkRenameForm from .mixins import ChangelogMessageMixin __all__ = ( - 'BulkRenameForm', + 'NetBoxModelBulkRenameForm', ) -class BulkRenameForm(ChangelogMessageMixin, forms.Form): +class NetBoxModelBulkRenameForm(ChangelogMessageMixin, BulkRenameForm): """ - An extendable form to be used for renaming objects in bulk. + Extends BulkRenameForm with a changelog message field for NetBox models that support change logging. """ - find = forms.CharField( - strip=False - ) - replace = forms.CharField( - strip=False, - required=False - ) - use_regex = forms.BooleanField( - required=False, - initial=True, - label=_('Use regular expressions') - ) - - def clean(self): - super().clean() - - # Validate regular expression in "find" field - if self.cleaned_data['use_regex']: - try: - re.compile(self.cleaned_data['find']) - except re.error: - raise forms.ValidationError({ - 'find': "Invalid regular expression" - }) + pass diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index a6f6ec738a1..41eeaba88dc 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -22,13 +22,13 @@ from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate -from netbox.forms.bulk_rename import BulkRenameForm +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 from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.export import TableExport, stream_table_csv_response -from utilities.forms import BulkDeleteForm, restrict_form_fields +from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial from utilities.jobs import is_background_request, process_request_as_job @@ -882,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() @@ -891,11 +897,6 @@ class _Form(BulkRenameForm): self.form = _Form - # Remove changelog_message field if model doesn't support change logging. - # Mutating base_fields is safe here because _Form is created fresh per request above. - if not issubclass(self.queryset.model, ChangeLoggingMixin): - self.form.base_fields.pop('changelog_message', None) - def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 554e9defe7d..b2dee5721a4 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -1,3 +1,5 @@ +import re + from django import forms from django.utils.translation import gettext as _ @@ -8,6 +10,7 @@ __all__ = ( 'BulkDeleteForm', 'BulkEditForm', + 'BulkRenameForm', 'CSVModelForm', 'ConfirmationForm', 'DeleteForm', @@ -58,6 +61,36 @@ class BulkEditForm(BackgroundJobMixin, forms.Form): nullable_fields = () +class BulkRenameForm(forms.Form): + """ + An extendable form to be used for renaming objects in bulk. + """ + find = forms.CharField( + strip=False + ) + replace = forms.CharField( + strip=False, + required=False + ) + use_regex = forms.BooleanField( + required=False, + initial=True, + label=_('Use regular expressions') + ) + + def clean(self): + super().clean() + + # Validate regular expression in "find" field + if self.cleaned_data['use_regex']: + try: + re.compile(self.cleaned_data['find']) + except re.error: + raise forms.ValidationError({ + 'find': "Invalid regular expression" + }) + + class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=None, diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 29314839876..b29cbadf6a1 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -3,9 +3,9 @@ from dcim.models import Site from netbox.choices import ImportFormatChoices -from netbox.forms.bulk_rename import BulkRenameForm from utilities.forms.bulk_import import BulkImportForm from utilities.forms.fields.csv import CSVSelectWidget +from utilities.forms.forms import BulkRenameForm from utilities.forms.utils import ( expand_alphanumeric_pattern, expand_ipnetwork_pattern, diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 2b6725323d8..f51e51e6041 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -9,10 +9,9 @@ from extras.models import ConfigTemplate from ipam.models import VLAN, VRF, VLANGroup, VLANTranslationPolicy from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm -from netbox.forms.bulk_rename import BulkRenameForm from netbox.forms.mixins import OwnerMixin from tenancy.models import Tenant -from utilities.forms import add_blank_choice +from utilities.forms import BulkRenameForm, 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 From c3374bc3b7bf7fd2e49f0651a87f94d56fc11f4c Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 7 May 2026 10:58:47 -0500 Subject: [PATCH 4/4] Remove unused VMInterfaceBulkRenameForm and VirtualDiskBulkRenameForm These form classes have been dead code since BulkRenameView was refactored to dynamically create its form in __init__, which unconditionally overwrites any form set by subclasses. The classes were originally needed when BulkRenameView expected subclasses to provide their own form, but that design was replaced in 1dbae5b64. --- netbox/virtualization/forms/bulk_edit.py | 18 +----------------- netbox/virtualization/views.py | 2 -- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index f51e51e6041..6f0feaf0ea0 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -11,7 +11,7 @@ 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 @@ 'ClusterGroupBulkEditForm', 'ClusterTypeBulkEditForm', 'VMInterfaceBulkEditForm', - 'VMInterfaceBulkRenameForm', 'VirtualDiskBulkEditForm', - 'VirtualDiskBulkRenameForm', 'VirtualMachineBulkEditForm', 'VirtualMachineTypeBulkEditForm', ) @@ -343,13 +341,6 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): # 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 d6a758dad69..a90a20a7313 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)