Explorar o código

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 <noreply@anthropic.com>
Brian Tiemann hai 1 semana
pai
achega
a2d0034789

+ 42 - 11
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 import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 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 ModelMultipleChoiceField, MultipleHiddenInput
+from django.forms import ChoiceField, ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 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.
     # so "Select all N matching query" can expand across the full queryset.
     filterset = None
     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):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
@@ -893,19 +909,32 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
             else BulkRenameForm
             else BulkRenameForm
         )
         )
 
 
-        class _Form(base_form):
-            pk = ModelMultipleChoiceField(
+        rename_fields = self._get_rename_fields()
+        form_attrs = {
+            'pk': ModelMultipleChoiceField(
                 queryset=self.queryset,
                 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):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'change')
         return get_permission_for_model(self.queryset.model, 'change')
 
 
     def _rename_objects(self, form, selected_objects):
     def _rename_objects(self, form, selected_objects):
         renamed_pks = []
         renamed_pks = []
+        field_name = form.cleaned_data.get('field_name') or self.field_name
 
 
         for obj in selected_objects:
         for obj in selected_objects:
             # Take a snapshot of change-logged models
             # Take a snapshot of change-logged models
@@ -916,18 +945,19 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
             replace = form.cleaned_data['replace']
             replace = form.cleaned_data['replace']
             if form.cleaned_data['use_regex']:
             if form.cleaned_data['use_regex']:
                 try:
                 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
                 # Catch regex group reference errors
                 except re.error:
                 except re.error:
-                    obj.new_name = getattr(obj, self.field_name)
+                    obj.new_name = getattr(obj, field_name)
             else:
             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)
             renamed_pks.append(obj.pk)
 
 
         return renamed_pks
         return renamed_pks
 
 
     def post(self, request):
     def post(self, request):
         logger = logging.getLogger('netbox.views.BulkRenameView')
         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 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:
         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})
             form = self.form(request.POST, initial={'pk': pk_list})
 
 
             if form.is_valid():
             if form.is_valid():
+                field_name = form.cleaned_data.get('field_name') or self.field_name
                 try:
                 try:
                     with transaction.atomic(using=router.db_for_write(self.queryset.model)):
                     with transaction.atomic(using=router.db_for_write(self.queryset.model)):
                         renamed_pks = self._rename_objects(form, selected_objects)
                         renamed_pks = self._rename_objects(form, selected_objects)
@@ -950,12 +981,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
                             if issubclass(self.queryset.model, MPTTModel):
                             if issubclass(self.queryset.model, MPTTModel):
                                 with self.queryset.model.objects.delay_mptt_updates():
                                 with self.queryset.model.objects.delay_mptt_updates():
                                     for obj in selected_objects:
                                     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._changelog_message = form.cleaned_data.get('changelog_message', '')
                                         obj.save()
                                         obj.save()
                             else:
                             else:
                                 for obj in selected_objects:
                                 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._changelog_message = form.cleaned_data.get('changelog_message', '')
                                     obj.save()
                                     obj.save()
 
 
@@ -985,7 +1016,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
             form = self.form(initial={'pk': pk_list})
             form = self.form(initial={'pk': pk_list})
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'field_name': self.field_name,
+            'field_name': field_name,
             'form': form,
             'form': form,
             'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
             'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
             'selected_objects': selected_objects,
             'selected_objects': selected_objects,

+ 2 - 2
netbox/templates/generic/bulk_rename.html

@@ -36,8 +36,8 @@ Context:
         <table class="table">
         <table class="table">
             <thead>
             <thead>
                 <tr>
                 <tr>
-                    <th>{% trans "Current Name" %}</th>
-                    <th>{% trans "New Name" %}</th>
+                    <th>{% if field_name == 'label' %}{% trans "Current Label" %}{% else %}{% trans "Current Name" %}{% endif %}</th>
+                    <th>{% if field_name == 'label' %}{% trans "New Label" %}{% else %}{% trans "New Name" %}{% endif %}</th>
                 </tr>
                 </tr>
             </thead>
             </thead>
             <tbody>
             <tbody>

+ 26 - 0
netbox/utilities/testing/views.py

@@ -1117,6 +1117,32 @@ class ViewTestCases:
             for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)):
             for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)):
                 self.assertEqual(instance.name, f'{objects[i].name}X')
                 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(
     class PrimaryObjectViewTestCase(
         GetObjectViewTestCase,
         GetObjectViewTestCase,
         GetObjectChangelogViewTestCase,
         GetObjectChangelogViewTestCase,