Bladeren bron

Closes #19231: Add bulk renaming support for all models (#19795)

* Closes #19231: Add bulk renaming support for all models

* Introduce a template filter for getattr()

* Extend BulkRenameView to support arbitrary field names

* Address bulk renaming support for remaining models

* Bulk rename URL resolution should fail silently

* Update documentation

* Fix bulk button rendering for HTMX requests
Jeremy Stretch 7 maanden geleden
bovenliggende
commit
ce12de8b6d

+ 5 - 0
docs/plugins/development/views.md

@@ -64,6 +64,7 @@ Generic view classes (documented below) facilitate common operations, such as cr
 | `ObjectListView`     | View a list of objects                                 |
 | `BulkImportView`     | Import a set of new objects                            |
 | `BulkEditView`       | Edit multiple objects                                  |
+| `BulkRenameView`     | Rename multiple objects                                |
 | `BulkDeleteView`     | Delete multiple objects                                |
 
 !!! warning
@@ -171,6 +172,10 @@ Below are the class definitions for NetBox's multi-object views. These views han
     options:
       members: false
 
+::: netbox.views.generic.BulkRenameView
+    options:
+      members: false
+
 ::: netbox.views.generic.BulkDeleteView
     options:
       members:

+ 46 - 0
netbox/circuits/views.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 from dcim.views import PathTraceView
 from ipam.models import ASN
+from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.query import count_related
@@ -79,6 +80,11 @@ class ProviderBulkEditView(generic.BulkEditView):
     form = forms.ProviderBulkEditForm
 
 
+@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
+class ProviderBulkRenameView(generic.BulkRenameView):
+    queryset = Provider.objects.all()
+
+
 @register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
 class ProviderBulkDeleteView(generic.BulkDeleteView):
     queryset = Provider.objects.annotate(
@@ -141,6 +147,11 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
     form = forms.ProviderAccountBulkEditForm
 
 
+@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
+class ProviderAccountBulkRenameView(generic.BulkRenameView):
+    queryset = ProviderAccount.objects.all()
+
+
 @register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
 class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
     queryset = ProviderAccount.objects.annotate(
@@ -212,6 +223,11 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
     form = forms.ProviderNetworkBulkEditForm
 
 
+@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
+class ProviderNetworkBulkRenameView(generic.BulkRenameView):
+    queryset = ProviderNetwork.objects.all()
+
+
 @register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
 class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
     queryset = ProviderNetwork.objects.all()
@@ -271,6 +287,11 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
     form = forms.CircuitTypeBulkEditForm
 
 
+@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
+class CircuitTypeBulkRenameView(generic.BulkRenameView):
+    queryset = CircuitType.objects.all()
+
+
 @register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
 class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
@@ -337,6 +358,12 @@ class CircuitBulkEditView(generic.BulkEditView):
     form = forms.CircuitBulkEditForm
 
 
+@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
+class CircuitBulkRenameView(generic.BulkRenameView):
+    queryset = Circuit.objects.all()
+    field_name = 'cid'
+
+
 @register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
 class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
@@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView):
     filterset = filtersets.CircuitTerminationFilterSet
     filterset_form = forms.CircuitTerminationFilterForm
     table = tables.CircuitTerminationTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(CircuitTermination)
@@ -526,6 +554,11 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
     form = forms.CircuitGroupBulkEditForm
 
 
+@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
+class CircuitGroupBulkRenameView(generic.BulkRenameView):
+    queryset = CircuitGroup.objects.all()
+
+
 @register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
 class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitGroup.objects.all()
@@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
     filterset = filtersets.CircuitGroupAssignmentFilterSet
     filterset_form = forms.CircuitGroupAssignmentFilterForm
     table = tables.CircuitGroupAssignmentTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(CircuitGroupAssignment)
@@ -635,6 +669,11 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
     form = forms.VirtualCircuitTypeBulkEditForm
 
 
+@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
+class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
+    queryset = VirtualCircuitType.objects.all()
+
+
 @register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
 class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualCircuitType.objects.annotate(
@@ -697,6 +736,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
     form = forms.VirtualCircuitBulkEditForm
 
 
+@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
+class VirtualCircuitulkRenameView(generic.BulkRenameView):
+    queryset = VirtualCircuit.objects.all()
+    field_name = 'cid'
+
+
 class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -714,6 +759,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
     filterset = filtersets.VirtualCircuitTerminationFilterSet
     filterset_form = forms.VirtualCircuitTerminationFilterForm
     table = tables.VirtualCircuitTerminationTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(VirtualCircuitTermination)

+ 5 - 0
netbox/core/views.py

@@ -120,6 +120,11 @@ class DataSourceBulkEditView(generic.BulkEditView):
     form = forms.DataSourceBulkEditForm
 
 
+@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
+class DataSourceBulkRenameView(generic.BulkRenameView):
+    queryset = DataSource.objects.all()
+
+
 @register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
 class DataSourceBulkDeleteView(generic.BulkDeleteView):
     queryset = DataSource.objects.annotate(

+ 108 - 16
netbox/dcim/views.py

@@ -292,6 +292,11 @@ class RegionBulkEditView(generic.BulkEditView):
     form = forms.RegionBulkEditForm
 
 
+@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
+class RegionBulkRenameView(generic.BulkRenameView):
+    queryset = Region.objects.all()
+
+
 @register_model_view(Region, 'bulk_delete', path='delete', detail=False)
 class RegionBulkDeleteView(generic.BulkDeleteView):
     queryset = Region.objects.add_related_count(
@@ -418,6 +423,11 @@ class SiteGroupBulkEditView(generic.BulkEditView):
     form = forms.SiteGroupBulkEditForm
 
 
+@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
+class SiteGroupBulkRenameView(generic.BulkRenameView):
+    queryset = SiteGroup.objects.all()
+
+
 @register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
 class SiteGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = SiteGroup.objects.add_related_count(
@@ -503,6 +513,11 @@ class SiteBulkEditView(generic.BulkEditView):
     form = forms.SiteBulkEditForm
 
 
+@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
+class SiteBulkRenameView(generic.BulkRenameView):
+    queryset = Site.objects.all()
+
+
 @register_model_view(Site, 'bulk_delete', path='delete', detail=False)
 class SiteBulkDeleteView(generic.BulkDeleteView):
     queryset = Site.objects.all()
@@ -607,6 +622,11 @@ class LocationBulkEditView(generic.BulkEditView):
     form = forms.LocationBulkEditForm
 
 
+@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
+class LocationBulkRenameView(generic.BulkRenameView):
+    queryset = Location.objects.all()
+
+
 @register_model_view(Location, 'bulk_delete', path='delete', detail=False)
 class LocationBulkDeleteView(generic.BulkDeleteView):
     queryset = Location.objects.add_related_count(
@@ -672,6 +692,11 @@ class RackRoleBulkEditView(generic.BulkEditView):
     form = forms.RackRoleBulkEditForm
 
 
+@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
+class RackRoleBulkRenameView(generic.BulkRenameView):
+    queryset = RackRole.objects.all()
+
+
 @register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
 class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
@@ -731,6 +756,12 @@ class RackTypeBulkEditView(generic.BulkEditView):
     form = forms.RackTypeBulkEditForm
 
 
+@register_model_view(RackType, 'bulk_rename', path='rename', detail=False)
+class RackTypeBulkRenameView(generic.BulkRenameView):
+    queryset = RackType.objects.all()
+    field_name = 'model'
+
+
 @register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
 class RackTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = RackType.objects.all()
@@ -910,6 +941,11 @@ class RackBulkEditView(generic.BulkEditView):
     form = forms.RackBulkEditForm
 
 
+@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
+class RackBulkRenameView(generic.BulkRenameView):
+    queryset = Rack.objects.all()
+
+
 @register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
 class RackBulkDeleteView(generic.BulkDeleteView):
     queryset = Rack.objects.all()
@@ -927,6 +963,7 @@ class RackReservationListView(generic.ObjectListView):
     filterset = filtersets.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(RackReservation)
@@ -1043,6 +1080,11 @@ class ManufacturerBulkEditView(generic.BulkEditView):
     form = forms.ManufacturerBulkEditForm
 
 
+@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
+class ManufacturerBulkRenameView(generic.BulkRenameView):
+    queryset = Manufacturer.objects.all()
+
+
 @register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
 class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     queryset = Manufacturer.objects.annotate(
@@ -1290,6 +1332,12 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
     form = forms.DeviceTypeBulkEditForm
 
 
+@register_model_view(DeviceType, 'bulk_rename', path='rename', detail=False)
+class DeviceTypeBulkRenameView(generic.BulkRenameView):
+    queryset = DeviceType.objects.all()
+    field_name = 'model'
+
+
 @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceType.objects.annotate(
@@ -1346,6 +1394,11 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
     form = forms.ModuleTypeProfileBulkEditForm
 
 
+@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
+class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
+    queryset = ModuleTypeProfile.objects.all()
+
+
 @register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
 class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
     queryset = ModuleTypeProfile.objects.annotate(
@@ -1556,6 +1609,11 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
     form = forms.ModuleTypeBulkEditForm
 
 
+@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
+class ModuleTypeBulkRenameView(generic.BulkRenameView):
+    queryset = ModuleType.objects.all()
+
+
 @register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
 class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ModuleType.objects.annotate(
@@ -2030,6 +2088,11 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
     form = forms.DeviceRoleBulkEditForm
 
 
+@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
+class DeviceRoleBulkRenameView(generic.BulkRenameView):
+    queryset = DeviceRole.objects.all()
+
+
 @register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceRole.objects.annotate(
@@ -2091,6 +2154,11 @@ class PlatformBulkEditView(generic.BulkEditView):
     form = forms.PlatformBulkEditForm
 
 
+@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
+class PlatformBulkRenameView(generic.BulkRenameView):
+    queryset = Platform.objects.all()
+
+
 @register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
 class PlatformBulkDeleteView(generic.BulkDeleteView):
     queryset = Platform.objects.all()
@@ -2374,16 +2442,16 @@ class DeviceBulkEditView(generic.BulkEditView):
     form = forms.DeviceBulkEditForm
 
 
-@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
-class DeviceBulkDeleteView(generic.BulkDeleteView):
-    queryset = Device.objects.prefetch_related('device_type__manufacturer')
+@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
+class DeviceBulkRenameView(generic.BulkRenameView):
+    queryset = Device.objects.all()
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
 
 
-@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
-class DeviceBulkRenameView(generic.BulkRenameView):
-    queryset = Device.objects.all()
+@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
+class DeviceBulkDeleteView(generic.BulkDeleteView):
+    queryset = Device.objects.prefetch_related('device_type__manufacturer')
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
 
@@ -2398,6 +2466,7 @@ class ModuleListView(generic.ObjectListView):
     filterset = filtersets.ModuleFilterSet
     filterset_form = forms.ModuleFilterForm
     table = tables.ModuleTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(Module)
@@ -2453,7 +2522,6 @@ class ConsolePortListView(generic.ObjectListView):
     filterset = filtersets.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(ConsolePort)
@@ -2524,7 +2592,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
     filterset = filtersets.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(ConsoleServerPort)
@@ -2595,7 +2662,6 @@ class PowerPortListView(generic.ObjectListView):
     filterset = filtersets.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(PowerPort)
@@ -2666,7 +2732,6 @@ class PowerOutletListView(generic.ObjectListView):
     filterset = filtersets.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(PowerOutlet)
@@ -2737,7 +2802,6 @@ class InterfaceListView(generic.ObjectListView):
     filterset = filtersets.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(Interface)
@@ -2881,7 +2945,6 @@ class FrontPortListView(generic.ObjectListView):
     filterset = filtersets.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(FrontPort)
@@ -2952,7 +3015,6 @@ class RearPortListView(generic.ObjectListView):
     filterset = filtersets.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(RearPort)
@@ -3023,7 +3085,6 @@ class ModuleBayListView(generic.ObjectListView):
     filterset = filtersets.ModuleBayFilterSet
     filterset_form = forms.ModuleBayFilterForm
     table = tables.ModuleBayTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(ModuleBay)
@@ -3085,7 +3146,6 @@ class DeviceBayListView(generic.ObjectListView):
     filterset = filtersets.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(DeviceBay)
@@ -3228,7 +3288,6 @@ class InventoryItemListView(generic.ObjectListView):
     filterset = filtersets.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
-    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(InventoryItem)
@@ -3351,6 +3410,11 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
     form = forms.InventoryItemRoleBulkEditForm
 
 
+@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
+class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
+    queryset = InventoryItemRole.objects.all()
+
+
 @register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
 class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = InventoryItemRole.objects.annotate(
@@ -3548,6 +3612,12 @@ class CableBulkEditView(generic.BulkEditView):
     form = forms.CableBulkEditForm
 
 
+@register_model_view(Cable, 'bulk_rename', path='rename', detail=False)
+class CableBulkRenameView(generic.BulkRenameView):
+    queryset = Cable.objects.all()
+    field_name = 'label'
+
+
 @register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
 class CableBulkDeleteView(generic.BulkDeleteView):
     queryset = Cable.objects.prefetch_related(
@@ -3840,6 +3910,11 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
     form = forms.VirtualChassisBulkEditForm
 
 
+@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
+class VirtualChassisBulkRenameView(generic.BulkRenameView):
+    queryset = VirtualChassis.objects.all()
+
+
 @register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
 class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualChassis.objects.all()
@@ -3897,6 +3972,11 @@ class PowerPanelBulkEditView(generic.BulkEditView):
     form = forms.PowerPanelBulkEditForm
 
 
+@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
+class PowerPanelBulkRenameView(generic.BulkRenameView):
+    queryset = PowerPanel.objects.all()
+
+
 @register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
 class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPanel.objects.annotate(
@@ -3949,6 +4029,11 @@ class PowerFeedBulkEditView(generic.BulkEditView):
     form = forms.PowerFeedBulkEditForm
 
 
+@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
+class PowerFeedBulkRenameView(generic.BulkRenameView):
+    queryset = PowerFeed.objects.all()
+
+
 @register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
 class PowerFeedBulkDisconnectView(BulkDisconnectView):
     queryset = PowerFeed.objects.all()
@@ -3977,6 +4062,7 @@ class VirtualDeviceContextListView(generic.ObjectListView):
     filterset = filtersets.VirtualDeviceContextFilterSet
     filterset_form = forms.VirtualDeviceContextFilterForm
     table = tables.VirtualDeviceContextTable
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(VirtualDeviceContext)
@@ -4021,6 +4107,11 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
     form = forms.VirtualDeviceContextBulkEditForm
 
 
+@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
+class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
+    queryset = VirtualDeviceContext.objects.all()
+
+
 @register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)
 class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualDeviceContext.objects.all()
@@ -4038,6 +4129,7 @@ class MACAddressListView(generic.ObjectListView):
     filterset = filtersets.MACAddressFilterSet
     filterset_form = forms.MACAddressFilterForm
     table = tables.MACAddressTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(MACAddress)

+ 64 - 4
netbox/extras/views.py

@@ -97,6 +97,11 @@ class CustomFieldBulkEditView(generic.BulkEditView):
     form = forms.CustomFieldBulkEditForm
 
 
+@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
+class CustomFieldBulkRenameView(generic.BulkRenameView):
+    queryset = CustomField.objects.all()
+
+
 @register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
 class CustomFieldBulkDeleteView(generic.BulkDeleteView):
     queryset = CustomField.objects.select_related('choice_set')
@@ -166,6 +171,11 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
     form = forms.CustomFieldChoiceSetBulkEditForm
 
 
+@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
+class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
+    queryset = CustomFieldChoiceSet.objects.all()
+
+
 @register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
 class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
     queryset = CustomFieldChoiceSet.objects.all()
@@ -216,6 +226,11 @@ class CustomLinkBulkEditView(generic.BulkEditView):
     form = forms.CustomLinkBulkEditForm
 
 
+@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
+class CustomLinkBulkRenameView(generic.BulkRenameView):
+    queryset = CustomLink.objects.all()
+
+
 @register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
 class CustomLinkBulkDeleteView(generic.BulkDeleteView):
     queryset = CustomLink.objects.all()
@@ -233,7 +248,7 @@ class ExportTemplateListView(generic.ObjectListView):
     filterset = filtersets.ExportTemplateFilterSet
     filterset_form = forms.ExportTemplateFilterForm
     table = tables.ExportTemplateTable
-    actions = (AddObject, BulkImport, BulkSync, BulkEdit, BulkExport, BulkDelete)
+    actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(ExportTemplate)
@@ -267,6 +282,11 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
     form = forms.ExportTemplateBulkEditForm
 
 
+@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
+class ExportTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = ExportTemplate.objects.all()
+
+
 @register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
 class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
     queryset = ExportTemplate.objects.all()
@@ -327,6 +347,11 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
     form = forms.SavedFilterBulkEditForm
 
 
+@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
+class SavedFilterBulkRenameView(generic.BulkRenameView):
+    queryset = SavedFilter.objects.all()
+
+
 @register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
 class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
     queryset = SavedFilter.objects.all()
@@ -344,7 +369,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
     filterset = filtersets.TableConfigFilterSet
     filterset_form = forms.TableConfigFilterForm
     table = tables.TableConfigTable
-    actions = (BulkExport,)
+    actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(TableConfig)
@@ -384,6 +409,11 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
     form = forms.TableConfigBulkEditForm
 
 
+@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
+class TableConfigBulkRenameView(generic.BulkRenameView):
+    queryset = TableConfig.objects.all()
+
+
 @register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
 class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
     queryset = TableConfig.objects.all()
@@ -465,6 +495,11 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
     form = forms.NotificationGroupBulkEditForm
 
 
+@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
+class NotificationGroupBulkRenameView(generic.BulkRenameView):
+    queryset = NotificationGroup.objects.all()
+
+
 @register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
 class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = NotificationGroup.objects.all()
@@ -611,6 +646,11 @@ class WebhookBulkEditView(generic.BulkEditView):
     form = forms.WebhookBulkEditForm
 
 
+@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
+class WebhookBulkRenameView(generic.BulkRenameView):
+    queryset = Webhook.objects.all()
+
+
 @register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
 class WebhookBulkDeleteView(generic.BulkDeleteView):
     queryset = Webhook.objects.all()
@@ -661,6 +701,11 @@ class EventRuleBulkEditView(generic.BulkEditView):
     form = forms.EventRuleBulkEditForm
 
 
+@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
+class EventRuleBulkRenameView(generic.BulkRenameView):
+    queryset = EventRule.objects.all()
+
+
 @register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
 class EventRuleBulkDeleteView(generic.BulkDeleteView):
     queryset = EventRule.objects.all()
@@ -735,6 +780,11 @@ class TagBulkEditView(generic.BulkEditView):
     form = forms.TagBulkEditForm
 
 
+@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
+class TagBulkRenameView(generic.BulkRenameView):
+    queryset = Tag.objects.all()
+
+
 @register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
 class TagBulkDeleteView(generic.BulkDeleteView):
     queryset = Tag.objects.annotate(
@@ -753,7 +803,7 @@ class ConfigContextListView(generic.ObjectListView):
     filterset = filtersets.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     table = tables.ConfigContextTable
-    actions = (AddObject, BulkSync, BulkEdit, BulkDelete)
+    actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(ConfigContext)
@@ -814,6 +864,11 @@ class ConfigContextBulkEditView(generic.BulkEditView):
     form = forms.ConfigContextBulkEditForm
 
 
+@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
+class ConfigContextBulkRenameView(generic.BulkRenameView):
+    queryset = ConfigContext.objects.all()
+
+
 @register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
 class ConfigContextBulkDeleteView(generic.BulkDeleteView):
     queryset = ConfigContext.objects.all()
@@ -866,7 +921,7 @@ class ConfigTemplateListView(generic.ObjectListView):
     filterset = filtersets.ConfigTemplateFilterSet
     filterset_form = forms.ConfigTemplateFilterForm
     table = tables.ConfigTemplateTable
-    actions = (AddObject, BulkImport, BulkSync, BulkEdit, BulkExport, BulkDelete)
+    actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(ConfigTemplate)
@@ -900,6 +955,11 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
     form = forms.ConfigTemplateBulkEditForm
 
 
+@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
+class ConfigTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = ConfigTemplate.objects.all()
+
+
 @register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
 class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
     queryset = ConfigTemplate.objects.all()

+ 70 - 0
netbox/ipam/views.py

@@ -10,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet
 from dcim.forms import InterfaceFilterForm
 from dcim.models import Device, Interface, Site
 from ipam.tables import VLANTranslationRuleTable
+from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.tables import get_table_ordering
@@ -86,6 +87,11 @@ class VRFBulkEditView(generic.BulkEditView):
     form = forms.VRFBulkEditForm
 
 
+@register_model_view(VRF, 'bulk_rename', path='rename', detail=False)
+class VRFBulkRenameView(generic.BulkRenameView):
+    queryset = VRF.objects.all()
+
+
 @register_model_view(VRF, 'bulk_delete', path='delete', detail=False)
 class VRFBulkDeleteView(generic.BulkDeleteView):
     queryset = VRF.objects.all()
@@ -136,6 +142,11 @@ class RouteTargetBulkEditView(generic.BulkEditView):
     form = forms.RouteTargetBulkEditForm
 
 
+@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False)
+class RouteTargetBulkRenameView(generic.BulkRenameView):
+    queryset = RouteTarget.objects.all()
+
+
 @register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False)
 class RouteTargetBulkDeleteView(generic.BulkDeleteView):
     queryset = RouteTarget.objects.all()
@@ -195,6 +206,11 @@ class RIRBulkEditView(generic.BulkEditView):
     form = forms.RIRBulkEditForm
 
 
+@register_model_view(RIR, 'bulk_rename', path='rename', detail=False)
+class RIRBulkRenameView(generic.BulkRenameView):
+    queryset = RIR.objects.all()
+
+
 @register_model_view(RIR, 'bulk_delete', path='delete', detail=False)
 class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(
@@ -268,6 +284,11 @@ class ASNRangeBulkEditView(generic.BulkEditView):
     form = forms.ASNRangeBulkEditForm
 
 
+@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False)
+class ASNRangeBulkRenameView(generic.BulkRenameView):
+    queryset = ASNRange.objects.all()
+
+
 @register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False)
 class ASNRangeBulkDeleteView(generic.BulkDeleteView):
     queryset = ASNRange.objects.annotate_asn_counts()
@@ -335,6 +356,11 @@ class ASNBulkEditView(generic.BulkEditView):
     form = forms.ASNBulkEditForm
 
 
+@register_model_view(ASN, 'bulk_rename', path='rename', detail=False)
+class ASNBulkRenameView(generic.BulkRenameView):
+    queryset = ASN.objects.all()
+
+
 @register_model_view(ASN, 'bulk_delete', path='delete', detail=False)
 class ASNBulkDeleteView(generic.BulkDeleteView):
     queryset = ASN.objects.annotate(
@@ -356,6 +382,7 @@ class AggregateListView(generic.ObjectListView):
     filterset = filtersets.AggregateFilterSet
     filterset_form = forms.AggregateFilterForm
     table = tables.AggregateTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(Aggregate)
@@ -488,6 +515,11 @@ class RoleBulkEditView(generic.BulkEditView):
     form = forms.RoleBulkEditForm
 
 
+@register_model_view(Role, 'bulk_rename', path='rename', detail=False)
+class RoleBulkRenameView(generic.BulkRenameView):
+    queryset = Role.objects.all()
+
+
 @register_model_view(Role, 'bulk_delete', path='delete', detail=False)
 class RoleBulkDeleteView(generic.BulkDeleteView):
     queryset = Role.objects.all()
@@ -506,6 +538,7 @@ class PrefixListView(generic.ObjectListView):
     filterset_form = forms.PrefixFilterForm
     table = tables.PrefixTable
     template_name = 'ipam/prefix_list.html'
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(Prefix)
@@ -766,6 +799,11 @@ class IPRangeBulkEditView(generic.BulkEditView):
     form = forms.IPRangeBulkEditForm
 
 
+@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False)
+class IPRangeBulkRenameView(generic.BulkRenameView):
+    queryset = IPRange.objects.all()
+
+
 @register_model_view(IPRange, 'bulk_delete', path='delete', detail=False)
 class IPRangeBulkDeleteView(generic.BulkDeleteView):
     queryset = IPRange.objects.all()
@@ -783,6 +821,7 @@ class IPAddressListView(generic.ObjectListView):
     filterset = filtersets.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(IPAddress)
@@ -1006,6 +1045,11 @@ class VLANGroupBulkEditView(generic.BulkEditView):
     form = forms.VLANGroupBulkEditForm
 
 
+@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False)
+class VLANGroupBulkRenameView(generic.BulkRenameView):
+    queryset = VLANGroup.objects.all()
+
+
 @register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False)
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
@@ -1095,6 +1139,11 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
     form = forms.VLANTranslationPolicyBulkEditForm
 
 
+@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False)
+class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView):
+    queryset = VLANTranslationPolicy.objects.all()
+
+
 @register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False)
 class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANTranslationPolicy.objects.all()
@@ -1112,6 +1161,7 @@ class VLANTranslationRuleListView(generic.ObjectListView):
     filterset = filtersets.VLANTranslationRuleFilterSet
     filterset_form = forms.VLANTranslationRuleFilterForm
     table = tables.VLANTranslationRuleTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(VLANTranslationRule)
@@ -1244,6 +1294,11 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
     form = forms.FHRPGroupBulkEditForm
 
 
+@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False)
+class FHRPGroupBulkRenameView(generic.BulkRenameView):
+    queryset = FHRPGroup.objects.all()
+
+
 @register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False)
 class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = FHRPGroup.objects.all()
@@ -1371,6 +1426,11 @@ class VLANBulkEditView(generic.BulkEditView):
     form = forms.VLANBulkEditForm
 
 
+@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False)
+class VLANBulkRenameView(generic.BulkRenameView):
+    queryset = VLAN.objects.all()
+
+
 @register_model_view(VLAN, 'bulk_delete', path='delete', detail=False)
 class VLANBulkDeleteView(generic.BulkDeleteView):
     queryset = VLAN.objects.all()
@@ -1421,6 +1481,11 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
     form = forms.ServiceTemplateBulkEditForm
 
 
+@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False)
+class ServiceTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = ServiceTemplate.objects.all()
+
+
 @register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False)
 class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
     queryset = ServiceTemplate.objects.all()
@@ -1488,6 +1553,11 @@ class ServiceBulkEditView(generic.BulkEditView):
     form = forms.ServiceBulkEditForm
 
 
+@register_model_view(Service, 'bulk_rename', path='rename', detail=False)
+class ServiceBulkRenameView(generic.BulkRenameView):
+    queryset = Service.objects.all()
+
+
 @register_model_view(Service, 'bulk_delete', path='delete', detail=False)
 class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('parent')

+ 5 - 1
netbox/netbox/object_actions.py

@@ -1,4 +1,5 @@
 from django.urls import reverse
+from django.urls.exceptions import NoReverseMatch
 from django.utils.translation import gettext as _
 
 from core.models import ObjectType
@@ -42,7 +43,10 @@ class ObjectAction:
         kwargs = {
             kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
         }
-        return reverse(viewname, kwargs=kwargs)
+        try:
+            return reverse(viewname, kwargs=kwargs)
+        except NoReverseMatch:
+            return
 
     @classmethod
     def get_context(cls, context, obj):

+ 12 - 8
netbox/netbox/views/generic/bulk_views.py

@@ -22,7 +22,7 @@ from core.models import ObjectType
 from core.signals import clear_events
 from extras.choices import CustomFieldUIEditableChoices
 from extras.models import CustomField, ExportTemplate
-from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
+from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
@@ -55,13 +55,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
     Attributes:
         filterset: A django-filter FilterSet that is applied to the queryset
         filterset_form: The form class used to render filter options
-        actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
-            action names must be prefixed with `bulk_`. (See ActionsMixin.)
+        actions: An iterable of ObjectAction subclasses (see ActionsMixin)
     """
     template_name = 'generic/object_list.html'
     filterset = None
     filterset_form = None
-    actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete)
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
@@ -731,7 +730,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
 class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
     """
     An extendable view for renaming objects in bulk.
+
+    Attributes:
+        field_name: The name of the object attribute for which the value is being updated (defaults to "name")
     """
+    field_name = 'name'
     template_name = 'generic/bulk_rename.html'
 
     def __init__(self, *args, **kwargs):
@@ -761,12 +764,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
             replace = form.cleaned_data['replace']
             if form.cleaned_data['use_regex']:
                 try:
-                    obj.new_name = re.sub(find, replace, obj.name or '')
+                    obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, ''))
                 # Catch regex group reference errors
                 except re.error:
-                    obj.new_name = obj.name
+                    obj.new_name = getattr(obj, self.field_name)
             else:
-                obj.new_name = (obj.name or '').replace(find, replace)
+                obj.new_name = getattr(obj, self.field_name, '').replace(find, replace)
             renamed_pks.append(obj.pk)
 
         return renamed_pks
@@ -785,7 +788,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
 
                         if '_apply' in request.POST:
                             for obj in selected_objects:
-                                obj.name = obj.new_name
+                                setattr(obj, self.field_name, obj.new_name)
                                 obj.save()
 
                             # Enforce constrained permissions
@@ -815,6 +818,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
             selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
 
         return render(request, self.template_name, {
+            'field_name': self.field_name,
             'form': form,
             'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
             'selected_objects': selected_objects,

+ 2 - 2
netbox/netbox/views/generic/object_views.py

@@ -47,6 +47,7 @@ class ObjectView(ActionsMixin, BaseObjectView):
 
     Attributes:
         tab: A ViewTab instance for the view
+        actions: An iterable of ObjectAction subclasses (see ActionsMixin)
     """
     tab = None
     actions = (CloneObject, EditObject, DeleteObject)
@@ -96,8 +97,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
         table: The django-tables2 Table class used to render the child objects list
         filterset: A django-filter FilterSet that is applied to the queryset
         filterset_form: The form class used to render filter options
-        actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
-            action names must be prefixed with `bulk_`. (See ActionsMixin.)
+        actions: An iterable of ObjectAction subclasses (see ActionsMixin)
     """
     child_model = None
     table = None

+ 6 - 4
netbox/templates/generic/bulk_rename.html

@@ -42,10 +42,12 @@ Context:
             </thead>
             <tbody>
                 {% for obj in selected_objects %}
-                    <tr{% if obj.new_name and obj.name != obj.new_name %} class="success"{% endif %}>
-                        <td>{{ obj.name }}</td>
-                        <td>{{ obj.new_name }}</td>
-                    </tr>
+                    {% with obj_name=obj|getattr:field_name %}
+                        <tr{% if obj.new_name and obj_name != obj.new_name %} class="success"{% endif %}>
+                            <td>{{ obj_name }}</td>
+                            <td>{{ obj.new_name }}</td>
+                        </tr>
+                    {% endwith %}
                 {% endfor %}
             </tbody>
         </table>

+ 1 - 6
netbox/templates/htmx/table.html

@@ -27,12 +27,7 @@
   {# Update the bulk action buttons with new query parameters #}
   {% if actions %}
     <div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
-      {% if 'bulk_edit' in actions %}
-        {% bulk_edit_button model query_params=request.GET %}
-      {% endif %}
-      {% if 'bulk_delete' in actions %}
-        {% bulk_delete_button model query_params=request.GET %}
-      {% endif %}
+      {% action_buttons actions model multi=True %}
     </div>
   {% endif %}
 {% endif %}

+ 25 - 0
netbox/tenancy/views.py

@@ -71,6 +71,11 @@ class TenantGroupBulkEditView(generic.BulkEditView):
     form = forms.TenantGroupBulkEditForm
 
 
+@register_model_view(TenantGroup, 'bulk_rename', path='rename', detail=False)
+class TenantGroupBulkRenameView(generic.BulkRenameView):
+    queryset = TenantGroup.objects.all()
+
+
 @register_model_view(TenantGroup, 'bulk_delete', path='delete', detail=False)
 class TenantGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = TenantGroup.objects.add_related_count(
@@ -132,6 +137,11 @@ class TenantBulkEditView(generic.BulkEditView):
     form = forms.TenantBulkEditForm
 
 
+@register_model_view(Tenant, 'bulk_rename', path='rename', detail=False)
+class TenantBulkRenameView(generic.BulkRenameView):
+    queryset = Tenant.objects.all()
+
+
 @register_model_view(Tenant, 'bulk_delete', path='delete', detail=False)
 class TenantBulkDeleteView(generic.BulkDeleteView):
     queryset = Tenant.objects.all()
@@ -207,6 +217,11 @@ class ContactGroupBulkEditView(generic.BulkEditView):
     form = forms.ContactGroupBulkEditForm
 
 
+@register_model_view(ContactGroup, 'bulk_rename', path='rename', detail=False)
+class ContactGroupBulkRenameView(generic.BulkRenameView):
+    queryset = ContactGroup.objects.all()
+
+
 @register_model_view(ContactGroup, 'bulk_delete', path='delete', detail=False)
 class ContactGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ContactGroup.objects.add_related_count(
@@ -268,6 +283,11 @@ class ContactRoleBulkEditView(generic.BulkEditView):
     form = forms.ContactRoleBulkEditForm
 
 
+@register_model_view(ContactRole, 'bulk_rename', path='rename', detail=False)
+class ContactRoleBulkRenameView(generic.BulkRenameView):
+    queryset = ContactRole.objects.all()
+
+
 @register_model_view(ContactRole, 'bulk_delete', path='delete', detail=False)
 class ContactRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = ContactRole.objects.all()
@@ -331,6 +351,11 @@ class ContactBulkEditView(generic.BulkEditView):
             obj.groups.remove(*form.cleaned_data['remove_groups'])
 
 
+@register_model_view(Contact, 'bulk_rename', path='rename', detail=False)
+class ContactBulkRenameView(generic.BulkRenameView):
+    queryset = Contact.objects.all()
+
+
 @register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
 class ContactBulkDeleteView(generic.BulkDeleteView):
     queryset = Contact.objects.annotate(

+ 19 - 0
netbox/users/views.py

@@ -2,6 +2,7 @@ from django.db.models import Count
 
 from core.models import ObjectChange
 from core.tables import ObjectChangeTable
+from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
 from netbox.views import generic
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
@@ -18,6 +19,7 @@ class TokenListView(generic.ObjectListView):
     filterset = filtersets.TokenFilterSet
     filterset_form = forms.TokenFilterForm
     table = tables.TokenTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(Token)
@@ -111,6 +113,12 @@ class UserBulkEditView(generic.BulkEditView):
     form = forms.UserBulkEditForm
 
 
+@register_model_view(User, 'bulk_rename', path='rename', detail=False)
+class UserBulkRenameView(generic.BulkRenameView):
+    queryset = User.objects.all()
+    field_name = 'username'
+
+
 @register_model_view(User, 'bulk_delete', path='delete', detail=False)
 class UserBulkDeleteView(generic.BulkDeleteView):
     queryset = User.objects.all()
@@ -162,6 +170,11 @@ class GroupBulkEditView(generic.BulkEditView):
     form = forms.GroupBulkEditForm
 
 
+@register_model_view(Group, 'bulk_rename', path='rename', detail=False)
+class GroupBulkRenameView(generic.BulkRenameView):
+    queryset = Group.objects.all()
+
+
 @register_model_view(Group, 'bulk_delete', path='delete', detail=False)
 class GroupBulkDeleteView(generic.BulkDeleteView):
     queryset = Group.objects.annotate(users_count=Count('user')).order_by('name')
@@ -179,6 +192,7 @@ class ObjectPermissionListView(generic.ObjectListView):
     filterset = filtersets.ObjectPermissionFilterSet
     filterset_form = forms.ObjectPermissionFilterForm
     table = tables.ObjectPermissionTable
+    actions = (AddObject, BulkExport, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(ObjectPermission)
@@ -207,6 +221,11 @@ class ObjectPermissionBulkEditView(generic.BulkEditView):
     form = forms.ObjectPermissionBulkEditForm
 
 
+@register_model_view(ObjectPermission, 'bulk_rename', path='rename', detail=False)
+class ObjectPermissionBulkRenameView(generic.BulkRenameView):
+    queryset = ObjectPermission.objects.all()
+
+
 @register_model_view(ObjectPermission, 'bulk_delete', path='delete', detail=False)
 class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
     queryset = ObjectPermission.objects.all()

+ 5 - 3
netbox/utilities/templates/buttons/bulk_rename.html

@@ -1,3 +1,5 @@
-<button type="submit" name="_rename" {% formaction %}="{{ url }}" class="btn btn-yellow">
-  <i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
-</button>
+{% if url %}
+  <button type="submit" name="_rename" {% formaction %}="{{ url }}" class="btn btn-yellow">
+    <i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
+  </button>
+{% endif %}

+ 9 - 0
netbox/utilities/templatetags/builtins/filters.py

@@ -22,6 +22,7 @@ __all__ = (
     'content_type',
     'content_type_id',
     'fgcolor',
+    'getattr_',
     'isodate',
     'isodatetime',
     'isotime',
@@ -88,6 +89,14 @@ def fgcolor(value, dark='000000', light='ffffff'):
     return f'#{foreground_color(value, dark, light)}'
 
 
+@register.filter('getattr')
+def getattr_(instance, name):
+    """
+    Call getattr() on the object for the specified attribute.
+    """
+    return getattr(instance, name, None)
+
+
 @register.filter()
 def meta(model, attr):
     """

+ 24 - 2
netbox/virtualization/views.py

@@ -13,7 +13,9 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
-from netbox.object_actions import *
+from netbox.object_actions import (
+    AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename, DeleteObject, EditObject,
+)
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
@@ -75,6 +77,11 @@ class ClusterTypeBulkEditView(generic.BulkEditView):
     form = forms.ClusterTypeBulkEditForm
 
 
+@register_model_view(ClusterType, 'bulk_rename', path='rename', detail=False)
+class ClusterTypeBulkRenameView(generic.BulkRenameView):
+    queryset = ClusterType.objects.all()
+
+
 @register_model_view(ClusterType, 'bulk_delete', path='delete', detail=False)
 class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(
@@ -148,6 +155,11 @@ class ClusterGroupBulkEditView(generic.BulkEditView):
     form = forms.ClusterGroupBulkEditForm
 
 
+@register_model_view(ClusterGroup, 'bulk_rename', path='rename', detail=False)
+class ClusterGroupBulkRenameView(generic.BulkRenameView):
+    queryset = ClusterGroup.objects.all()
+
+
 @register_model_view(ClusterGroup, 'bulk_delete', path='delete', detail=False)
 class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
@@ -262,6 +274,11 @@ class ClusterBulkEditView(generic.BulkEditView):
     form = forms.ClusterBulkEditForm
 
 
+@register_model_view(Cluster, 'bulk_rename', path='rename', detail=False)
+class ClusterBulkRenameView(generic.BulkRenameView):
+    queryset = Cluster.objects.all()
+
+
 @register_model_view(Cluster, 'bulk_delete', path='delete', detail=False)
 class ClusterBulkDeleteView(generic.BulkDeleteView):
     queryset = Cluster.objects.all()
@@ -322,7 +339,7 @@ class VirtualMachineListView(generic.ObjectListView):
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
     table = tables.VirtualMachineTable
-    actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkDelete)
+    actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(VirtualMachine)
@@ -417,6 +434,11 @@ class VirtualMachineBulkEditView(generic.BulkEditView):
     form = forms.VirtualMachineBulkEditForm
 
 
+@register_model_view(VirtualMachine, 'bulk_rename', path='rename', detail=False)
+class VirtualMachineBulkRenameView(generic.BulkRenameView):
+    queryset = VirtualMachine.objects.all()
+
+
 @register_model_view(VirtualMachine, 'bulk_delete', path='delete', detail=False)
 class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')

+ 42 - 0
netbox/vpn/views.py

@@ -1,4 +1,5 @@
 from ipam.tables import RouteTargetTable
+from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.views import GetRelatedModelsMixin, register_model_view
@@ -58,6 +59,11 @@ class TunnelGroupBulkEditView(generic.BulkEditView):
     form = forms.TunnelGroupBulkEditForm
 
 
+@register_model_view(TunnelGroup, 'bulk_rename', path='rename', detail=False)
+class TunnelGroupBulkRenameView(generic.BulkRenameView):
+    queryset = TunnelGroup.objects.all()
+
+
 @register_model_view(TunnelGroup, 'bulk_delete', path='delete', detail=False)
 class TunnelGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = TunnelGroup.objects.annotate(
@@ -122,6 +128,11 @@ class TunnelBulkEditView(generic.BulkEditView):
     form = forms.TunnelBulkEditForm
 
 
+@register_model_view(Tunnel, 'bulk_rename', path='rename', detail=False)
+class TunnelBulkRenameView(generic.BulkRenameView):
+    queryset = Tunnel.objects.all()
+
+
 @register_model_view(Tunnel, 'bulk_delete', path='delete', detail=False)
 class TunnelBulkDeleteView(generic.BulkDeleteView):
     queryset = Tunnel.objects.annotate(
@@ -224,6 +235,11 @@ class IKEProposalBulkEditView(generic.BulkEditView):
     form = forms.IKEProposalBulkEditForm
 
 
+@register_model_view(IKEProposal, 'bulk_rename', path='rename', detail=False)
+class IKEProposalBulkRenameView(generic.BulkRenameView):
+    queryset = IKEProposal.objects.all()
+
+
 @register_model_view(IKEProposal, 'bulk_delete', path='delete', detail=False)
 class IKEProposalBulkDeleteView(generic.BulkDeleteView):
     queryset = IKEProposal.objects.all()
@@ -274,6 +290,11 @@ class IKEPolicyBulkEditView(generic.BulkEditView):
     form = forms.IKEPolicyBulkEditForm
 
 
+@register_model_view(IKEPolicy, 'bulk_rename', path='rename', detail=False)
+class IKEPolicyBulkRenameView(generic.BulkRenameView):
+    queryset = IKEPolicy.objects.all()
+
+
 @register_model_view(IKEPolicy, 'bulk_delete', path='delete', detail=False)
 class IKEPolicyBulkDeleteView(generic.BulkDeleteView):
     queryset = IKEPolicy.objects.all()
@@ -324,6 +345,11 @@ class IPSecProposalBulkEditView(generic.BulkEditView):
     form = forms.IPSecProposalBulkEditForm
 
 
+@register_model_view(IPSecProposal, 'bulk_rename', path='rename', detail=False)
+class IPSecProposalBulkRenameView(generic.BulkRenameView):
+    queryset = IPSecProposal.objects.all()
+
+
 @register_model_view(IPSecProposal, 'bulk_delete', path='delete', detail=False)
 class IPSecProposalBulkDeleteView(generic.BulkDeleteView):
     queryset = IPSecProposal.objects.all()
@@ -374,6 +400,11 @@ class IPSecPolicyBulkEditView(generic.BulkEditView):
     form = forms.IPSecPolicyBulkEditForm
 
 
+@register_model_view(IPSecPolicy, 'bulk_rename', path='rename', detail=False)
+class IPSecPolicyBulkRenameView(generic.BulkRenameView):
+    queryset = IPSecPolicy.objects.all()
+
+
 @register_model_view(IPSecPolicy, 'bulk_delete', path='delete', detail=False)
 class IPSecPolicyBulkDeleteView(generic.BulkDeleteView):
     queryset = IPSecPolicy.objects.all()
@@ -424,6 +455,11 @@ class IPSecProfileBulkEditView(generic.BulkEditView):
     form = forms.IPSecProfileBulkEditForm
 
 
+@register_model_view(IPSecProfile, 'bulk_rename', path='rename', detail=False)
+class IPSecProfileBulkRenameView(generic.BulkRenameView):
+    queryset = IPSecProfile.objects.all()
+
+
 @register_model_view(IPSecProfile, 'bulk_delete', path='delete', detail=False)
 class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
     queryset = IPSecProfile.objects.all()
@@ -491,6 +527,11 @@ class L2VPNBulkEditView(generic.BulkEditView):
     form = forms.L2VPNBulkEditForm
 
 
+@register_model_view(L2VPN, 'bulk_rename', path='rename', detail=False)
+class L2VPNBulkRenameView(generic.BulkRenameView):
+    queryset = L2VPN.objects.all()
+
+
 @register_model_view(L2VPN, 'bulk_delete', path='delete', detail=False)
 class L2VPNBulkDeleteView(generic.BulkDeleteView):
     queryset = L2VPN.objects.all()
@@ -508,6 +549,7 @@ class L2VPNTerminationListView(generic.ObjectListView):
     table = tables.L2VPNTerminationTable
     filterset = filtersets.L2VPNTerminationFilterSet
     filterset_form = forms.L2VPNTerminationFilterForm
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
 
 
 @register_model_view(L2VPNTermination)

+ 17 - 0
netbox/wireless/views.py

@@ -68,6 +68,11 @@ class WirelessLANGroupBulkEditView(generic.BulkEditView):
     form = forms.WirelessLANGroupBulkEditForm
 
 
+@register_model_view(WirelessLANGroup, 'bulk_rename', path='rename', detail=False)
+class WirelessLANGroupBulkRenameView(generic.BulkRenameView):
+    queryset = WirelessLANGroup.objects.all()
+
+
 @register_model_view(WirelessLANGroup, 'bulk_delete', path='delete', detail=False)
 class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = WirelessLANGroup.objects.add_related_count(
@@ -137,6 +142,12 @@ class WirelessLANBulkEditView(generic.BulkEditView):
     form = forms.WirelessLANBulkEditForm
 
 
+@register_model_view(WirelessLAN, 'bulk_rename', path='rename', detail=False)
+class WirelessLANBulkRenameView(generic.BulkRenameView):
+    queryset = WirelessLAN.objects.all()
+    field_name = 'ssid'
+
+
 @register_model_view(WirelessLAN, 'bulk_delete', path='delete', detail=False)
 class WirelessLANBulkDeleteView(generic.BulkDeleteView):
     queryset = WirelessLAN.objects.all()
@@ -187,6 +198,12 @@ class WirelessLinkBulkEditView(generic.BulkEditView):
     form = forms.WirelessLinkBulkEditForm
 
 
+@register_model_view(WirelessLink, 'bulk_rename', path='rename', detail=False)
+class WirelessLinkBulkRenameView(generic.BulkRenameView):
+    queryset = WirelessLink.objects.all()
+    field_name = 'ssid'
+
+
 @register_model_view(WirelessLink, 'bulk_delete', path='delete', detail=False)
 class WirelessLinkBulkDeleteView(generic.BulkDeleteView):
     queryset = WirelessLink.objects.all()