From a2d00347897edf53e34d63e12fcfc0456f5725c6 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 14 May 2026 08:34:57 -0400 Subject: [PATCH] Closes #20804: Add field selector to BulkRenameView for models with a label field Device/module component models (Interface, ConsolePort, FrontPort, etc. and their template counterparts) have both a 'name' and a 'label' field. The bulk rename form now shows a 'Field' dropdown on these models so users can choose which field to apply the find/replace pattern to; the selector is omitted for models that have only one renameable field. Co-Authored-By: Claude Sonnet 4.6 --- netbox/netbox/views/generic/bulk_views.py | 53 ++++++++++++++++++----- netbox/templates/generic/bulk_rename.html | 4 +- netbox/utilities/testing/views.py | 26 +++++++++++ 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 95da9ce76..3e8f29948 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -10,7 +10,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, Valida from django.db import IntegrityError, router, transaction from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import ModelMultipleChoiceField, MultipleHiddenInput +from django.forms import ChoiceField, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe @@ -883,6 +883,22 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): # so "Select all N matching query" can expand across the full queryset. filterset = None + def _get_rename_fields(self): + """ + Return a list of (value, label) choices for the rename field selector. + + The selector is shown only when the view targets the default 'name' field and + the model also has a 'label' field (device/module components and their templates). + Views that hardcode a non-default field_name (e.g. Cable → 'label') return an + empty list so no selector is rendered. + """ + if self.field_name != 'name': + return [] + model_field_names = {f.name for f in self.queryset.model._meta.get_fields()} + if 'label' not in model_field_names: + return [] + return [('name', _('Name')), ('label', _('Label'))] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -893,19 +909,32 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): else BulkRenameForm ) - class _Form(base_form): - pk = ModelMultipleChoiceField( + rename_fields = self._get_rename_fields() + form_attrs = { + 'pk': ModelMultipleChoiceField( queryset=self.queryset, - widget=MultipleHiddenInput() + widget=MultipleHiddenInput(), + ), + } + if rename_fields: + form_attrs['field_name'] = ChoiceField( + choices=rename_fields, + label=_('Field'), + initial='name', + # Django's ChoiceField.validate() skips the valid_value() check when the + # value is empty, so required=False lets existing callers (tests, scripts) + # omit the field entirely; the view falls back to self.field_name. + required=False, ) - self.form = _Form + self.form = type('_Form', (base_form,), form_attrs) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') def _rename_objects(self, form, selected_objects): renamed_pks = [] + field_name = form.cleaned_data.get('field_name') or self.field_name for obj in selected_objects: # Take a snapshot of change-logged models @@ -916,18 +945,19 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): replace = form.cleaned_data['replace'] if form.cleaned_data['use_regex']: try: - obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, '') or '') + obj.new_name = re.sub(find, replace, getattr(obj, field_name, '') or '') # Catch regex group reference errors except re.error: - obj.new_name = getattr(obj, self.field_name) + obj.new_name = getattr(obj, field_name) else: - obj.new_name = (getattr(obj, self.field_name, '') or '').replace(find, replace) + obj.new_name = (getattr(obj, field_name, '') or '').replace(find, replace) renamed_pks.append(obj.pk) return renamed_pks def post(self, request): logger = logging.getLogger('netbox.views.BulkRenameView') + field_name = self.field_name # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. if request.POST.get('_all') and self.filterset is not None: @@ -941,6 +971,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): form = self.form(request.POST, initial={'pk': pk_list}) if form.is_valid(): + field_name = form.cleaned_data.get('field_name') or self.field_name try: with transaction.atomic(using=router.db_for_write(self.queryset.model)): renamed_pks = self._rename_objects(form, selected_objects) @@ -950,12 +981,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): if issubclass(self.queryset.model, MPTTModel): with self.queryset.model.objects.delay_mptt_updates(): for obj in selected_objects: - setattr(obj, self.field_name, obj.new_name) + setattr(obj, 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) + setattr(obj, field_name, obj.new_name) obj._changelog_message = form.cleaned_data.get('changelog_message', '') obj.save() @@ -985,7 +1016,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): form = self.form(initial={'pk': pk_list}) return render(request, self.template_name, { - 'field_name': self.field_name, + 'field_name': field_name, 'form': form, 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'selected_objects': selected_objects, diff --git a/netbox/templates/generic/bulk_rename.html b/netbox/templates/generic/bulk_rename.html index 5c18a5e44..a52d0e1ef 100644 --- a/netbox/templates/generic/bulk_rename.html +++ b/netbox/templates/generic/bulk_rename.html @@ -36,8 +36,8 @@ Context: - - + + diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 38edc8aa9..68b3e0ea9 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1117,6 +1117,32 @@ 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_label_field(self): + """When field_name='label' is submitted, labels (not names) are updated.""" + if 'label' not in {f.name for f in self.model._meta.get_fields()}: + self.skipTest("Model does not have a label field") + + objects = self._get_queryset().all()[:3] + pk_list = [obj.pk for obj in objects] + original_labels = [obj.label for obj in objects] + data = { + 'pk': pk_list, + 'field_name': 'label', + '_apply': True, + } + data.update(self.rename_data) + + 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) + for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): + self.assertEqual(instance.label, f'{original_labels[i]}X') + self.assertEqual(instance.name, objects[i].name) + class PrimaryObjectViewTestCase( GetObjectViewTestCase, GetObjectChangelogViewTestCase,
{% trans "Current Name" %}{% trans "New Name" %}{% if field_name == 'label' %}{% trans "Current Label" %}{% else %}{% trans "Current Name" %}{% endif %}{% if field_name == 'label' %}{% trans "New Label" %}{% else %}{% trans "New Name" %}{% endif %}