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>
This commit is contained in:
Brian Tiemann 2026-05-14 08:34:57 -04:00 committed by Jeremy Stretch
parent f9135392f1
commit a2d0034789
3 changed files with 70 additions and 13 deletions

View File

@ -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,

View File

@ -36,8 +36,8 @@ Context:
<table class="table">
<thead>
<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>
</thead>
<tbody>

View File

@ -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,