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:
parent
f9135392f1
commit
a2d0034789
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue