From c438c130458347773e547438f4e6964d7ad15349 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Jun 2025 17:19:36 -0400 Subject: [PATCH 01/20] Initial work on #19735 --- netbox/netbox/object_actions.py | 123 ++++++++++++++++++++ netbox/netbox/views/generic/bulk_views.py | 5 +- netbox/netbox/views/generic/mixins.py | 10 +- netbox/netbox/views/generic/object_views.py | 7 +- netbox/templates/generic/object.html | 12 +- netbox/templates/generic/object_list.html | 36 +++--- netbox/utilities/templatetags/buttons.py | 13 +++ 7 files changed, 170 insertions(+), 36 deletions(-) create mode 100644 netbox/netbox/object_actions.py diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py new file mode 100644 index 00000000000..0e8f9115586 --- /dev/null +++ b/netbox/netbox/object_actions.py @@ -0,0 +1,123 @@ +from django.urls import reverse +from django.utils.translation import gettext as _ + +from core.models import ObjectType +from extras.models import ExportTemplate + +__all__ = ( + 'Add', + 'BulkDelete', + 'BulkEdit', + 'BulkExport', + 'BulkImport', + 'Delete', + 'Edit', + 'ObjectAction', +) + + +class ObjectAction: + name = '' + label = None + bulk = False + permissions_required = set() + url_kwargs = [] + + def get_context(self, context, obj): + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{self.name}' + url = reverse(viewname, kwargs={kwarg: getattr(obj, kwarg) for kwarg in self.url_kwargs}) + return { + 'url': url, + } + + +class Add(ObjectAction): + """ + Create a new object. + """ + name = 'add' + label = _('Add') + permissions_required = {'add'} + template_name = 'buttons/add.html' + + +class Edit(ObjectAction): + """ + Edit a single object. + """ + name = 'edit' + label = _('Edit') + permissions_required = {'change'} + url_kwargs = ['pk'] + template_name = 'buttons/edit.html' + + +class Delete(ObjectAction): + """ + Delete a single object. + """ + name = 'delete' + label = _('Delete') + permissions_required = {'delete'} + url_kwargs = ['pk'] + template_name = 'buttons/delete.html' + + +class BulkImport(ObjectAction): + """ + Import multiple objects at once. + """ + name = 'bulk_import' + label = _('Import') + permissions_required = {'add'} + template_name = 'buttons/import.html' + + +class BulkExport(ObjectAction): + """ + Export multiple objects at once. + """ + name = 'export' + label = _('Export') + permissions_required = {'view'} + template_name = 'buttons/export.html' + + def get_context(self, context, model): + object_type = ObjectType.objects.get_for_model(model) + user = context['request'].user + + # Determine if the "all data" export returns CSV or YAML + data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV' + + # Retrieve all export templates for this model + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type) + + return { + 'perms': context['perms'], + 'object_type': object_type, + 'url_params': context['request'].GET.urlencode() if context['request'].GET else '', + 'export_templates': export_templates, + 'data_format': data_format, + } + + +class BulkEdit(ObjectAction): + """ + Change the value of one or more fields on a set of objects. + """ + name = 'bulk_edit' + label = _('Edit') + bulk = True + permissions_required = {'change'} + template_name = 'buttons/bulk_edit.html' + + +class BulkDelete(ObjectAction): + """ + Delete each of a set of objects. + """ + name = 'bulk_delete' + label = _('Delete') + bulk = True + permissions_required = {'delete'} + template_name = 'buttons/bulk_delete.html' diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index b52d12d9869..548e945d1ad 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -22,6 +22,7 @@ from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate +from netbox.object_actions import Add, BulkDelete, BulkEdit, BulkExport, BulkImport from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields @@ -60,6 +61,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): template_name = 'generic/object_list.html' filterset = None filterset_form = None + actions = (Add, BulkImport, BulkEdit, BulkExport, BulkDelete) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -150,7 +152,8 @@ def get(self, request): # Determine the available actions actions = self.get_permitted_actions(request.user) - has_bulk_actions = any([a.startswith('bulk_') for a in actions]) + # has_bulk_actions = any([a.startswith('bulk_') for a in actions]) + has_bulk_actions = True if 'export' in request.GET: diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 5f9f621207c..1576e127e41 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -1,7 +1,6 @@ from django.shortcuts import get_object_or_404 from extras.models import TableConfig -from netbox.constants import DEFAULT_ACTION_PERMISSIONS from utilities.permissions import get_permission_for_model __all__ = ( @@ -19,7 +18,7 @@ class ActionsMixin: Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map with custom actions, such as bulk_sync. """ - actions = DEFAULT_ACTION_PERMISSIONS + # actions = DEFAULT_ACTION_PERMISSIONS def get_permitted_actions(self, user, model=None): """ @@ -30,13 +29,16 @@ def get_permitted_actions(self, user, model=None): # Resolve required permissions for each action permitted_actions = [] for action in self.actions: + perms = action if type(action) is str else action.permissions_required # Backward compatibility required_permissions = [ - get_permission_for_model(model, name) for name in self.actions.get(action, set()) + get_permission_for_model(model, perm) for perm in perms ] if not required_permissions or user.has_perms(required_permissions): permitted_actions.append(action) - return permitted_actions + return { + action.name: action for action in permitted_actions + } class TableMixin: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a7acbffc069..f33b0ba6c2c 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -14,6 +14,7 @@ from django.utils.translation import gettext as _ from core.signals import clear_events +from netbox.object_actions import Add, BulkDelete, BulkEdit, BulkExport, BulkImport, Delete, Edit from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ConfirmationForm, restrict_form_fields @@ -36,7 +37,7 @@ ) -class ObjectView(BaseObjectView): +class ObjectView(ActionsMixin, BaseObjectView): """ Retrieve a single object for display. @@ -46,6 +47,7 @@ class ObjectView(BaseObjectView): tab: A ViewTab instance for the view """ tab = None + actions = (Edit, Delete) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -72,9 +74,11 @@ def get(self, request, **kwargs): request: The current request """ instance = self.get_object(**kwargs) + actions = self.get_permitted_actions(request.user, model=instance) return render(request, self.get_template_name(), { 'object': instance, + 'actions': actions, 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -97,6 +101,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): table = None filterset = None filterset_form = None + actions = (Add, BulkImport, BulkEdit, BulkExport, BulkDelete) template_name = 'generic/object_children.html' def get_children(self, request, parent): diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 42083280a1a..67a43471639 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -80,15 +80,9 @@ {% if perms.extras.add_subscription and object.subscriptions %} {% subscribe_button object %} {% endif %} - {% if request.user|can_add:object %} - {% clone_button object %} - {% endif %} - {% if request.user|can_change:object %} - {% edit_button object %} - {% endif %} - {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} + {% for name, action in actions.items %} + {% action_button action object %} + {% endfor %} {% endblock control-buttons %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index e6d5505a4e2..491569be0ef 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -31,15 +31,11 @@
{% plugin_list_buttons model %} {% block extra_controls %}{% endblock %} - {% if 'add' in actions %} - {% add_button model %} - {% endif %} - {% if 'bulk_import' in actions %} - {% import_button model %} - {% endif %} - {% if 'export' in actions %} - {% export_button model %} - {% endif %} + {% for name, action in actions.items %} + {% if not action.bulk %} + {% action_button action model %} + {% endif %} + {% endfor %}
{% endblock controls %} @@ -91,12 +87,11 @@
- {% 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 %} + {% for name, action in actions.items %} + {% if action.bulk %} + {% bulk_action_button action model %} + {% endif %} + {% endfor %}
@@ -124,12 +119,11 @@
{% block bulk_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 %} + {% for name, action in actions.items %} + {% if action.bulk %} + {% bulk_action_button action model %} + {% endif %} + {% endfor %}
{% endblock %}
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index d38c8863f7e..086586c7417 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -1,5 +1,6 @@ from django import template from django.contrib.contenttypes.models import ContentType +from django.template import loader from django.urls import NoReverseMatch, reverse from core.models import ObjectType @@ -9,8 +10,10 @@ from utilities.views import get_viewname __all__ = ( + 'action_button', 'add_button', 'bookmark_button', + 'bulk_action_button', 'bulk_delete_button', 'bulk_edit_button', 'clone_button', @@ -217,3 +220,13 @@ def bulk_delete_button(context, model, action='bulk_delete', query_params=None): 'htmx_navigation': context.get('htmx_navigation'), 'url': url, } + + +@register.simple_tag(takes_context=True) +def action_button(context, action, obj): + return loader.render_to_string(action.template_name, action.get_context(context, obj)) + + +@register.simple_tag(takes_context=True) +def bulk_action_button(context, action, model): + return loader.render_to_string(action.template_name, action.get_context(context, model)) From 3e26ce950cef2faf714e616955d9363626a3b071 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jun 2025 17:10:05 -0400 Subject: [PATCH 02/20] Work in progress --- netbox/core/object_actions.py | 18 +++ netbox/core/views.py | 14 +-- netbox/dcim/object_actions.py | 18 +++ netbox/dcim/views.py | 119 +++++------------- netbox/extras/views.py | 37 ++---- netbox/netbox/constants.py | 3 +- netbox/netbox/object_actions.py | 24 +++- netbox/netbox/views/generic/mixins.py | 26 +++- netbox/netbox/views/generic/object_views.py | 3 +- netbox/templates/dcim/component_list.html | 22 ---- .../dcim/device/components_base.html | 23 ---- .../templates/dcim/device/consoleports.html | 28 ----- .../dcim/device/consoleserverports.html | 28 ----- netbox/templates/dcim/device/devicebays.html | 14 --- netbox/templates/dcim/device/frontports.html | 28 ----- netbox/templates/dcim/device/interfaces.html | 29 +---- netbox/templates/dcim/device/inventory.html | 14 --- netbox/templates/dcim/device/modulebays.html | 14 --- .../templates/dcim/device/poweroutlets.html | 28 ----- netbox/templates/dcim/device/powerports.html | 28 ----- netbox/templates/dcim/device/rearports.html | 28 ----- .../dcim/devicetype/component_templates.html | 40 +++--- .../templates/extras/configtemplate_list.html | 11 -- .../templates/extras/exporttemplate_list.html | 11 -- netbox/templates/generic/object_children.html | 56 +++++---- netbox/templates/generic/object_list.html | 8 +- .../virtualmachine/interfaces.html | 14 --- netbox/tenancy/views.py | 8 +- .../templates/buttons/bulk_disconnect.html | 6 + .../buttons/bulk_remove_devices.html | 6 + .../templates/buttons/bulk_rename.html | 6 + .../templates/buttons/bulk_sync.html | 6 + netbox/utilities/templatetags/buttons.py | 4 + netbox/virtualization/views.py | 22 +--- 34 files changed, 211 insertions(+), 533 deletions(-) create mode 100644 netbox/core/object_actions.py create mode 100644 netbox/dcim/object_actions.py delete mode 100644 netbox/templates/dcim/component_list.html delete mode 100644 netbox/templates/dcim/device/components_base.html delete mode 100644 netbox/templates/dcim/device/consoleports.html delete mode 100644 netbox/templates/dcim/device/consoleserverports.html delete mode 100644 netbox/templates/dcim/device/devicebays.html delete mode 100644 netbox/templates/dcim/device/frontports.html delete mode 100644 netbox/templates/dcim/device/inventory.html delete mode 100644 netbox/templates/dcim/device/modulebays.html delete mode 100644 netbox/templates/dcim/device/poweroutlets.html delete mode 100644 netbox/templates/dcim/device/powerports.html delete mode 100644 netbox/templates/dcim/device/rearports.html delete mode 100644 netbox/templates/extras/configtemplate_list.html delete mode 100644 netbox/templates/extras/exporttemplate_list.html delete mode 100644 netbox/templates/virtualization/virtualmachine/interfaces.html create mode 100644 netbox/utilities/templates/buttons/bulk_disconnect.html create mode 100644 netbox/utilities/templates/buttons/bulk_remove_devices.html create mode 100644 netbox/utilities/templates/buttons/bulk_rename.html create mode 100644 netbox/utilities/templates/buttons/bulk_sync.html diff --git a/netbox/core/object_actions.py b/netbox/core/object_actions.py new file mode 100644 index 00000000000..b650037642c --- /dev/null +++ b/netbox/core/object_actions.py @@ -0,0 +1,18 @@ +from django.utils.translation import gettext as _ + +from netbox.object_actions import ObjectAction + +__all__ = ( + 'BulkSync', +) + + +class BulkSync(ObjectAction): + """ + Synchronize multiple objects at once. + """ + name = 'bulk_sync' + label = _('Sync Data') + bulk = True + permissions_required = {'sync'} + template_name = 'buttons/bulk_sync.html' diff --git a/netbox/core/views.py b/netbox/core/views.py index ef52147f1f2..c766fdf80d0 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -22,6 +22,7 @@ from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job from netbox.config import get_config, PARAMS +from netbox.object_actions import BulkDelete, BulkExport from netbox.registry import registry from netbox.views import generic from netbox.views.generic.base import BaseObjectView @@ -138,9 +139,7 @@ class DataFileListView(generic.ObjectListView): filterset = filtersets.DataFileFilterSet filterset_form = forms.DataFileFilterForm table = tables.DataFileTable - actions = { - 'bulk_delete': {'delete'}, - } + actions = (BulkDelete,) @register_model_view(DataFile) @@ -170,10 +169,7 @@ class JobListView(generic.ObjectListView): filterset = filtersets.JobFilterSet filterset_form = forms.JobFilterForm table = tables.JobTable - actions = { - 'export': {'view'}, - 'bulk_delete': {'delete'}, - } + actions = (BulkExport, BulkDelete) @register_model_view(Job) @@ -204,9 +200,7 @@ class ObjectChangeListView(generic.ObjectListView): filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable template_name = 'core/objectchange_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) @register_model_view(ObjectChange) diff --git a/netbox/dcim/object_actions.py b/netbox/dcim/object_actions.py new file mode 100644 index 00000000000..d9a124733d0 --- /dev/null +++ b/netbox/dcim/object_actions.py @@ -0,0 +1,18 @@ +from django.utils.translation import gettext as _ + +from netbox.object_actions import ObjectAction + +__all__ = ( + 'BulkDisconnect', +) + + +class BulkDisconnect(ObjectAction): + """ + Disconnect each of a set of objects to which a cable is connected. + """ + name = 'bulk_disconnect' + label = _('Disconnect Selected') + bulk = True + permissions_required = {'change'} + template_name = 'buttons/bulk_disconnect.html' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 304438698ca..5614310f50f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -15,7 +15,7 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable -from netbox.constants import DEFAULT_ACTION_PERMISSIONS +from netbox.object_actions import * from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -34,6 +34,7 @@ from . import filtersets, forms, tables from .choices import DeviceFaceChoices, InterfaceModeChoices from .models import * +from .object_actions import BulkDisconnect CABLE_TERMINATION_TYPES = { 'dcim.consoleport': ConsolePort, @@ -49,11 +50,6 @@ class DeviceComponentsView(generic.ObjectChildrenView): - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - 'bulk_disconnect': {'change'}, - } queryset = Device.objects.all() def get_children(self, request, parent): @@ -61,10 +57,7 @@ def get_children(self, request, parent): class DeviceTypeComponentsView(generic.ObjectChildrenView): - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) queryset = DeviceType.objects.all() template_name = 'dcim/devicetype/component_templates.html' viewname = None # Used for return_url resolution @@ -78,8 +71,9 @@ def get_extra_context(self, request, instance): } -class ModuleTypeComponentsView(DeviceComponentsView): +class ModuleTypeComponentsView(generic.ObjectChildrenView): queryset = ModuleType.objects.all() + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) template_name = 'dcim/moduletype/component_templates.html' viewname = None # Used for return_url resolution @@ -2157,7 +2151,7 @@ class DeviceConsolePortsView(DeviceComponentsView): table = tables.DeviceConsolePortTable filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm - template_name = 'dcim/device/consoleports.html', + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) tab = ViewTab( label=_('Console Ports'), badge=lambda obj: obj.console_port_count, @@ -2173,7 +2167,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView): table = tables.DeviceConsoleServerPortTable filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm - template_name = 'dcim/device/consoleserverports.html' + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) tab = ViewTab( label=_('Console Server Ports'), badge=lambda obj: obj.console_server_port_count, @@ -2189,7 +2183,7 @@ class DevicePowerPortsView(DeviceComponentsView): table = tables.DevicePowerPortTable filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm - template_name = 'dcim/device/powerports.html' + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) tab = ViewTab( label=_('Power Ports'), badge=lambda obj: obj.power_port_count, @@ -2205,7 +2199,7 @@ class DevicePowerOutletsView(DeviceComponentsView): table = tables.DevicePowerOutletTable filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm - template_name = 'dcim/device/poweroutlets.html' + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) tab = ViewTab( label=_('Power Outlets'), badge=lambda obj: obj.power_outlet_count, @@ -2221,6 +2215,7 @@ class DeviceInterfacesView(DeviceComponentsView): table = tables.DeviceInterfaceTable filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) template_name = 'dcim/device/interfaces.html' tab = ViewTab( label=_('Interfaces'), @@ -2243,7 +2238,7 @@ class DeviceFrontPortsView(DeviceComponentsView): table = tables.DeviceFrontPortTable filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm - template_name = 'dcim/device/frontports.html' + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) tab = ViewTab( label=_('Front Ports'), badge=lambda obj: obj.front_port_count, @@ -2259,7 +2254,7 @@ class DeviceRearPortsView(DeviceComponentsView): table = tables.DeviceRearPortTable filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm - template_name = 'dcim/device/rearports.html' + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) tab = ViewTab( label=_('Rear Ports'), badge=lambda obj: obj.rear_port_count, @@ -2275,11 +2270,7 @@ class DeviceModuleBaysView(DeviceComponentsView): table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm - template_name = 'dcim/device/modulebays.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Module Bays'), badge=lambda obj: obj.module_bay_count, @@ -2295,11 +2286,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm - template_name = 'dcim/device/devicebays.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Device Bays'), badge=lambda obj: obj.device_bay_count, @@ -2315,11 +2302,7 @@ class DeviceInventoryView(DeviceComponentsView): table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm - template_name = 'dcim/device/inventory.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_count, @@ -2472,11 +2455,7 @@ class ConsolePortListView(generic.ObjectListView): filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(ConsolePort) @@ -2547,11 +2526,7 @@ class ConsoleServerPortListView(generic.ObjectListView): filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(ConsoleServerPort) @@ -2622,11 +2597,7 @@ class PowerPortListView(generic.ObjectListView): filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(PowerPort) @@ -2697,11 +2668,7 @@ class PowerOutletListView(generic.ObjectListView): filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(PowerOutlet) @@ -2772,11 +2739,7 @@ class InterfaceListView(generic.ObjectListView): filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(Interface) @@ -2920,11 +2883,7 @@ class FrontPortListView(generic.ObjectListView): filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(FrontPort) @@ -2995,11 +2954,7 @@ class RearPortListView(generic.ObjectListView): filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(RearPort) @@ -3070,11 +3025,7 @@ class ModuleBayListView(generic.ObjectListView): filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(ModuleBay) @@ -3136,11 +3087,7 @@ class DeviceBayListView(generic.ObjectListView): filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(DeviceBay) @@ -3283,11 +3230,7 @@ class InventoryItemListView(generic.ObjectListView): filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Add, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(InventoryItem) @@ -3627,9 +3570,7 @@ class ConsoleConnectionsListView(generic.ObjectListView): filterset_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable template_name = 'dcim/connections_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) def get_extra_context(self, request): return { @@ -3643,9 +3584,7 @@ class PowerConnectionsListView(generic.ObjectListView): filterset_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable template_name = 'dcim/connections_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) def get_extra_context(self, request): return { @@ -3659,9 +3598,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): filterset_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable template_name = 'dcim/connections_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) def get_extra_context(self, request): return { diff --git a/netbox/extras/views.py b/netbox/extras/views.py index ea465a4a40f..7f274491bce 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,12 +14,13 @@ from core.choices import ManagedFileRootPathChoices from core.models import Job +from core.object_actions import BulkSync from dcim.models import Device, DeviceRole, Platform from extras.choices import LogLevelChoices from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from extras.utils import SharedObjectViewMixin -from netbox.constants import DEFAULT_ACTION_PERMISSIONS +from netbox.object_actions import * from netbox.views import generic from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value @@ -232,11 +233,7 @@ class ExportTemplateListView(generic.ObjectListView): filterset = filtersets.ExportTemplateFilterSet filterset_form = forms.ExportTemplateFilterForm table = tables.ExportTemplateTable - template_name = 'extras/exporttemplate_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_sync': {'sync'}, - } + actions = (Add, BulkImport, BulkSync, BulkEdit, BulkExport, BulkDelete) @register_model_view(ExportTemplate) @@ -347,9 +344,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView): filterset = filtersets.TableConfigFilterSet filterset_form = forms.TableConfigFilterForm table = tables.TableConfigTable - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) @register_model_view(TableConfig) @@ -759,12 +754,7 @@ class ConfigContextListView(generic.ObjectListView): filterset_form = forms.ConfigContextFilterForm table = tables.ConfigContextTable template_name = 'extras/configcontext_list.html' - actions = { - 'add': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - 'bulk_sync': {'sync'}, - } + actions = (Add, BulkSync, BulkEdit, BulkDelete) @register_model_view(ConfigContext) @@ -877,11 +867,7 @@ class ConfigTemplateListView(generic.ObjectListView): filterset = filtersets.ConfigTemplateFilterSet filterset_form = forms.ConfigTemplateFilterForm table = tables.ConfigTemplateTable - template_name = 'extras/configtemplate_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_sync': {'sync'}, - } + actions = (Add, BulkImport, BulkSync, BulkEdit, BulkExport, BulkDelete) @register_model_view(ConfigTemplate) @@ -992,9 +978,7 @@ class ImageAttachmentListView(generic.ObjectListView): filterset = filtersets.ImageAttachmentFilterSet filterset_form = forms.ImageAttachmentFilterForm table = tables.ImageAttachmentTable - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) @register_model_view(ImageAttachment, 'add', detail=False) @@ -1038,12 +1022,7 @@ class JournalEntryListView(generic.ObjectListView): filterset = filtersets.JournalEntryFilterSet filterset_form = forms.JournalEntryFilterForm table = tables.JournalEntryTable - actions = { - 'export': {'view'}, - 'bulk_import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - } + actions = (BulkImport, BulkSync, BulkEdit, BulkDelete) @register_model_view(JournalEntry) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 8d20fed4566..f088c8e4abd 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -28,7 +28,8 @@ 'job-schedules': 110100, } -# Default view action permission mapping +# TODO: Remove in NetBox v4.6 +# Legacy default view action permission mapping DEFAULT_ACTION_PERMISSIONS = { 'add': {'add'}, 'export': {'view'}, diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py index 0e8f9115586..123e64f84c5 100644 --- a/netbox/netbox/object_actions.py +++ b/netbox/netbox/object_actions.py @@ -10,6 +10,7 @@ 'BulkEdit', 'BulkExport', 'BulkImport', + 'BulkRename', 'Delete', 'Edit', 'ObjectAction', @@ -23,11 +24,13 @@ class ObjectAction: permissions_required = set() url_kwargs = [] - def get_context(self, context, obj): - viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{self.name}' - url = reverse(viewname, kwargs={kwarg: getattr(obj, kwarg) for kwarg in self.url_kwargs}) + @classmethod + def get_context(cls, context, obj): + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}' + url = reverse(viewname, kwargs={kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs}) return { 'url': url, + 'label': cls.label, } @@ -106,18 +109,29 @@ class BulkEdit(ObjectAction): Change the value of one or more fields on a set of objects. """ name = 'bulk_edit' - label = _('Edit') + label = _('Edit Selected') bulk = True permissions_required = {'change'} template_name = 'buttons/bulk_edit.html' +class BulkRename(ObjectAction): + """ + Rename multiple objects at once. + """ + name = 'bulk_rename' + label = _('Rename Selected') + bulk = True + permissions_required = {'change'} + template_name = 'buttons/bulk_rename.html' + + class BulkDelete(ObjectAction): """ Delete each of a set of objects. """ name = 'bulk_delete' - label = _('Delete') + label = _('Delete Selected') bulk = True permissions_required = {'delete'} template_name = 'buttons/bulk_delete.html' diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 1576e127e41..f43512b0972 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -1,6 +1,7 @@ from django.shortcuts import get_object_or_404 from extras.models import TableConfig +from netbox import object_actions from utilities.permissions import get_permission_for_model __all__ = ( @@ -18,7 +19,27 @@ class ActionsMixin: Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map with custom actions, such as bulk_sync. """ - # actions = DEFAULT_ACTION_PERMISSIONS + + # TODO: Remove in NetBox v4.6 + @staticmethod + def _get_legacy_action(name): + """ + Given a legacy action name, return the corresponding action class. + """ + action = { + 'add': object_actions.Add, + 'edit': object_actions.Edit, + 'delete': object_actions.Delete, + 'export': object_actions.BulkExport, + 'bulk_import': object_actions.BulkImport, + 'bulk_edit': object_actions.BulkEdit, + 'bulk_rename': object_actions.BulkRename, + 'bulk_delete': object_actions.BulkDelete, + }.get(name) + if name is None: + raise ValueError(f"Unknown action: {action}") + + return action def get_permitted_actions(self, user, model=None): """ @@ -29,7 +50,8 @@ def get_permitted_actions(self, user, model=None): # Resolve required permissions for each action permitted_actions = [] for action in self.actions: - perms = action if type(action) is str else action.permissions_required # Backward compatibility + # Backward compatibility + perms = self._get_legacy_action(action) if type(action) is str else action.permissions_required required_permissions = [ get_permission_for_model(model, perm) for perm in perms ] diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index f33b0ba6c2c..7ac2e7b113a 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,7 +143,8 @@ def get(self, request, *args, **kwargs): # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) - has_bulk_actions = any([a.startswith('bulk_') for a in actions]) + # has_bulk_actions = any([a.startswith('bulk_') for a in actions]) + has_bulk_actions = True table_data = self.prep_table_data(request, child_objects, instance) table = self.get_table(table_data, request, has_bulk_actions) diff --git a/netbox/templates/dcim/component_list.html b/netbox/templates/dcim/component_list.html deleted file mode 100644 index 6f91aff3ec4..00000000000 --- a/netbox/templates/dcim/component_list.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load buttons %} -{% load helpers %} -{% load i18n %} - -{% block bulk_buttons %} -
- {% if 'bulk_edit' in actions %} - {% bulk_edit_button model query_params=request.GET %} - {% endif %} - {% if 'bulk_rename' in actions %} - {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %} - - {% endwith %} - {% endif %} -
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/device/components_base.html b/netbox/templates/dcim/device/components_base.html deleted file mode 100644 index 47b0d7aabdc..00000000000 --- a/netbox/templates/dcim/device/components_base.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'generic/object_children.html' %} -{% load helpers %} - -{% block bulk_edit_controls %} - {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} - {% if 'bulk_edit' in actions and bulk_edit_view %} - - {% endif %} - {% endwith %} - {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} - {% if 'bulk_rename' in actions and bulk_rename_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_edit_controls %} diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html deleted file mode 100644 index d8de85868be..00000000000 --- a/netbox/templates/dcim/device/consoleports.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_consoleport %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html deleted file mode 100644 index 29efdb65bde..00000000000 --- a/netbox/templates/dcim/device/consoleserverports.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_consoleserverport %} - - {% endif %} -{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html deleted file mode 100644 index b558cdfb453..00000000000 --- a/netbox/templates/dcim/device/devicebays.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load i18n %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_devicebay %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html deleted file mode 100644 index 4d7f147698e..00000000000 --- a/netbox/templates/dcim/device/frontports.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_frontport %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 8d46d77eaa0..be81f97cb0b 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -1,30 +1,5 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} +{% extends 'generic/object_children.html' %} {% block table_controls %} - {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %} + {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %} {% endblock table_controls %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_interface %} - - {% trans "Add Interfaces" %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html deleted file mode 100644 index cbc113d8694..00000000000 --- a/netbox/templates/dcim/device/inventory.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load i18n %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_inventoryitem %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html deleted file mode 100644 index c23532efe66..00000000000 --- a/netbox/templates/dcim/device/modulebays.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load i18n %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_modulebay %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html deleted file mode 100644 index 66c765181d9..00000000000 --- a/netbox/templates/dcim/device/poweroutlets.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_poweroutlet %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html deleted file mode 100644 index a299130a446..00000000000 --- a/netbox/templates/dcim/device/powerports.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_powerport %} - - {% endif %} -{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html deleted file mode 100644 index 184e80d9920..00000000000 --- a/netbox/templates/dcim/device/rearports.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'dcim/device/components_base.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} - {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_delete_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if perms.dcim.add_rearport %} - - {% endif %} -{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/devicetype/component_templates.html b/netbox/templates/dcim/devicetype/component_templates.html index 291c7c98824..4d136194215 100644 --- a/netbox/templates/dcim/devicetype/component_templates.html +++ b/netbox/templates/dcim/devicetype/component_templates.html @@ -3,23 +3,23 @@ {% load i18n %} {% load perms %} -{% block bulk_edit_controls %} - {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} - {% if 'bulk_edit' in actions and bulk_edit_view %} - - {% endif %} - {% endwith %} - {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} - {% if 'bulk_rename' in actions and bulk_rename_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_edit_controls %} +{#{% block bulk_edit_controls %}#} +{# {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}#} +{# {% if 'bulk_edit' in actions and bulk_edit_view %}#} +{# #} +{# {% endif %}#} +{# {% endwith %}#} +{# {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}#} +{# {% if 'bulk_rename' in actions and bulk_rename_view %}#} +{# #} +{# {% endif %}#} +{# {% endwith %}#} +{#{% endblock bulk_edit_controls %}#} diff --git a/netbox/templates/extras/configtemplate_list.html b/netbox/templates/extras/configtemplate_list.html deleted file mode 100644 index c9db67e36c4..00000000000 --- a/netbox/templates/extras/configtemplate_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load i18n %} - -{% block bulk_buttons %} - {% if perms.extras.sync_configtemplate %} - - {% endif %} - {{ block.super }} -{% endblock %} diff --git a/netbox/templates/extras/exporttemplate_list.html b/netbox/templates/extras/exporttemplate_list.html deleted file mode 100644 index 4284336a533..00000000000 --- a/netbox/templates/extras/exporttemplate_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load i18n %} - -{% block bulk_buttons %} - {% if perms.extras.sync_configcontext %} - - {% endif %} - {{ block.super }} -{% endblock %} diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index c4d6dc52acc..93d7323e4d6 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -1,4 +1,5 @@ {% extends base_template %} +{% load buttons %} {% load helpers %} {% load i18n %} @@ -36,34 +37,37 @@
{% block bulk_controls %} -
+ {% for name, action in actions.items %} + {% bulk_action_button action model %} + {% endfor %} +{#
#} {# Bulk edit buttons #} - {% block bulk_edit_controls %} - {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} - {% if 'bulk_edit' in actions and bulk_edit_view %} - - {% endif %} - {% endwith %} - {% endblock bulk_edit_controls %} -
-
+{# {% block bulk_edit_controls %}#} +{# {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}#} +{# {% if 'bulk_edit' in actions and bulk_edit_view %}#} +{# #} +{# {% endif %}#} +{# {% endwith %}#} +{# {% endblock bulk_edit_controls %}#} +{#
#} +{#
#} {# Bulk delete buttons #} - {% block bulk_delete_controls %} - {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %} - {% if 'bulk_delete' in actions and bulk_delete_view %} - - {% endif %} - {% endwith %} - {% endblock bulk_delete_controls %} -
+{# {% block bulk_delete_controls %}#} +{# {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}#} +{# {% if 'bulk_delete' in actions and bulk_delete_view %}#} +{# #} +{# {% endif %}#} +{# {% endwith %}#} +{# {% endblock bulk_delete_controls %}#} +{#
#} {# Other bulk action buttons #} {% block bulk_extra_controls %}{% endblock %} {% endblock bulk_controls %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 491569be0ef..ec15d17de8d 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -88,9 +88,7 @@
{% for name, action in actions.items %} - {% if action.bulk %} - {% bulk_action_button action model %} - {% endif %} + {% bulk_action_button action model %} {% endfor %}
@@ -120,9 +118,7 @@ {% block bulk_buttons %}
{% for name, action in actions.items %} - {% if action.bulk %} - {% bulk_action_button action model %} - {% endif %} + {% bulk_action_button action model %} {% endfor %}
{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html deleted file mode 100644 index f74bd977068..00000000000 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'generic/object_children.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_edit_controls %} - {{ block.super }} - {% if 'bulk_rename' in actions %} - - {% endif %} -{% endblock bulk_edit_controls %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index aca0a14eb67..83794f21fef 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 +from netbox.object_actions import BulkDelete, BulkEdit, BulkExport, BulkImport from netbox.views import generic from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view @@ -349,12 +350,7 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable - actions = { - 'export': {'view'}, - 'bulk_import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - } + actions = (BulkExport, BulkImport, BulkEdit, BulkDelete) @register_model_view(ContactAssignment, 'add', detail=False) diff --git a/netbox/utilities/templates/buttons/bulk_disconnect.html b/netbox/utilities/templates/buttons/bulk_disconnect.html new file mode 100644 index 00000000000..d5e6e6cd761 --- /dev/null +++ b/netbox/utilities/templates/buttons/bulk_disconnect.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% if url %} + +{% endif %} diff --git a/netbox/utilities/templates/buttons/bulk_remove_devices.html b/netbox/utilities/templates/buttons/bulk_remove_devices.html new file mode 100644 index 00000000000..3a5d45420d0 --- /dev/null +++ b/netbox/utilities/templates/buttons/bulk_remove_devices.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% if url %} + +{% endif %} diff --git a/netbox/utilities/templates/buttons/bulk_rename.html b/netbox/utilities/templates/buttons/bulk_rename.html new file mode 100644 index 00000000000..aa1588e8d7b --- /dev/null +++ b/netbox/utilities/templates/buttons/bulk_rename.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% if url %} + +{% endif %} diff --git a/netbox/utilities/templates/buttons/bulk_sync.html b/netbox/utilities/templates/buttons/bulk_sync.html new file mode 100644 index 00000000000..2563f0a95f1 --- /dev/null +++ b/netbox/utilities/templates/buttons/bulk_sync.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% if url %} + +{% endif %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 086586c7417..37e1e514fa3 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -224,9 +224,13 @@ def bulk_delete_button(context, model, action='bulk_delete', query_params=None): @register.simple_tag(takes_context=True) def action_button(context, action, obj): + if action.bulk: + return '' return loader.render_to_string(action.template_name, action.get_context(context, obj)) @register.simple_tag(takes_context=True) def bulk_action_button(context, action, model): + if not action.bulk: + return '' return loader.render_to_string(action.template_name, action.get_context(context, model)) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6013a56f485..7a638498dfd 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -13,7 +13,7 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import IPAddress, VLANGroup from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable -from netbox.constants import DEFAULT_ACTION_PERMISSIONS +from netbox.object_actions import * from netbox.views import generic from utilities.query import count_related from utilities.query_functions import CollateAsChar @@ -223,13 +223,7 @@ class ClusterDevicesView(generic.ObjectChildrenView): filterset = DeviceFilterSet filterset_form = DeviceFilterForm template_name = 'virtualization/cluster/devices.html' - actions = { - 'add': {'add'}, - 'export': {'view'}, - 'bulk_import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_remove_devices': {'change'}, - } + actions = (Add, BulkExport, BulkImport, BulkEdit) tab = ViewTab( label=_('Devices'), badge=lambda obj: obj.devices.count(), @@ -386,11 +380,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): table = tables.VirtualMachineVMInterfaceTable filterset = filtersets.VMInterfaceFilterSet filterset_form = forms.VMInterfaceFilterForm - template_name = 'virtualization/virtualmachine/interfaces.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Interfaces'), badge=lambda obj: obj.interface_count, @@ -412,17 +402,13 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView): table = tables.VirtualMachineVirtualDiskTable filterset = filtersets.VirtualDiskFilterSet filterset_form = forms.VirtualDiskFilterForm - template_name = 'virtualization/virtualmachine/virtual_disks.html' + actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Virtual Disks'), badge=lambda obj: obj.virtual_disk_count, permission='virtualization.view_virtualdisk', weight=500 ) - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } def get_children(self, request, parent): return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags') From 56eb5d28d1f1bc56c7061440dd04bc2349ee2140 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jun 2025 20:42:17 -0400 Subject: [PATCH 03/20] Remove ClusterRemoveDevicesView (anti-pattern) --- netbox/templates/generic/bulk_remove.html | 72 ------------------- .../virtualization/cluster/devices.html | 13 ---- .../buttons/bulk_remove_devices.html | 6 -- netbox/virtualization/views.py | 48 +------------ 4 files changed, 2 insertions(+), 137 deletions(-) delete mode 100644 netbox/templates/generic/bulk_remove.html delete mode 100644 netbox/templates/virtualization/cluster/devices.html delete mode 100644 netbox/utilities/templates/buttons/bulk_remove_devices.html diff --git a/netbox/templates/generic/bulk_remove.html b/netbox/templates/generic/bulk_remove.html deleted file mode 100644 index d0ba2309711..00000000000 --- a/netbox/templates/generic/bulk_remove.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends 'generic/_base.html' %} -{% load helpers %} -{% load render_table from django_tables2 %} -{% load i18n %} - -{% comment %} -Blocks: - - title: Page title - - tabs: Page tabs - - content: Primary page content - -Context: - - form: The bulk edit form class - - parent_obj: The parent object - - table: A table of objects being removed - - obj_type_plural: The plural form of the object type - - return_url: The URL to which the user is redirected after submitting the form -{% endcomment %} - -{% block title %} - {% trans "Remove" %} {{ table.rows|length }} {{ obj_type_plural|bettertitle }}? -{% endblock %} - -{% block tabs %} - -{% endblock tabs %} - -{% block content %} -
- -
-
-
- {% render_table table 'inc/table.html' %} -
-
-
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
- {% trans "Cancel" %} - -
-
-
-
-{% endblock content %} diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html deleted file mode 100644 index a71fab8b537..00000000000 --- a/netbox/templates/virtualization/cluster/devices.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends 'generic/object_children.html' %} -{% load i18n %} - -{% block bulk_delete_controls %} - {{ block.super }} - {% if 'bulk_remove_devices' in actions %} - - {% endif %} -{% endblock bulk_delete_controls %} diff --git a/netbox/utilities/templates/buttons/bulk_remove_devices.html b/netbox/utilities/templates/buttons/bulk_remove_devices.html deleted file mode 100644 index 3a5d45420d0..00000000000 --- a/netbox/utilities/templates/buttons/bulk_remove_devices.html +++ /dev/null @@ -1,6 +0,0 @@ -{% load i18n %} -{% if url %} - -{% endif %} diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 7a638498dfd..81a10b2119f 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -204,6 +204,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView): table = tables.VirtualMachineTable filterset = filtersets.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm + actions = (Edit, Delete, BulkEdit) tab = ViewTab( label=_('Virtual Machines'), badge=lambda obj: obj.virtual_machines.count(), @@ -222,8 +223,7 @@ class ClusterDevicesView(generic.ObjectChildrenView): table = DeviceTable filterset = DeviceFilterSet filterset_form = DeviceFilterForm - template_name = 'virtualization/cluster/devices.html' - actions = (Add, BulkExport, BulkImport, BulkEdit) + actions = (Edit, Delete, BulkEdit) tab = ViewTab( label=_('Devices'), badge=lambda obj: obj.devices.count(), @@ -311,50 +311,6 @@ def post(self, request, pk): }) -@register_model_view(Cluster, 'remove_devices', path='devices/remove') -class ClusterRemoveDevicesView(generic.ObjectEditView): - queryset = Cluster.objects.all() - form = forms.ClusterRemoveDevicesForm - template_name = 'generic/bulk_remove.html' - - def post(self, request, pk): - - cluster = get_object_or_404(self.queryset, pk=pk) - - if '_confirm' in request.POST: - form = self.form(request.POST) - if form.is_valid(): - - device_pks = form.cleaned_data['pk'] - with transaction.atomic(using=router.db_for_write(Device)): - - # Remove the selected Devices from the Cluster - for device in Device.objects.filter(pk__in=device_pks): - device.cluster = None - device.save() - - messages.success(request, _("Removed {count} devices from cluster {cluster}").format( - count=len(device_pks), - cluster=cluster - )) - return redirect(cluster.get_absolute_url()) - - else: - form = self.form(initial={'pk': request.POST.getlist('pk')}) - - selected_objects = Device.objects.filter(pk__in=form.initial['pk']) - device_table = DeviceTable(list(selected_objects), orderable=False) - device_table.configure(request) - - return render(request, self.template_name, { - 'form': form, - 'parent_obj': cluster, - 'table': device_table, - 'obj_type_plural': 'devices', - 'return_url': cluster.get_absolute_url(), - }) - - # # Virtual machines # From 7c0fbd170373358c554d5bfddc8f1e8e13048746 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jun 2025 21:12:58 -0400 Subject: [PATCH 04/20] Misc cleanup --- netbox/dcim/views.py | 2 -- netbox/netbox/object_actions.py | 12 +++++-- netbox/netbox/views/generic/mixins.py | 1 + .../dcim/devicetype/component_templates.html | 25 --------------- .../dcim/moduletype/component_templates.html | 30 ------------------ netbox/templates/generic/object_children.html | 31 +------------------ .../virtualmachine/virtual_disks.html | 14 --------- 7 files changed, 11 insertions(+), 104 deletions(-) delete mode 100644 netbox/templates/dcim/devicetype/component_templates.html delete mode 100644 netbox/templates/dcim/moduletype/component_templates.html delete mode 100644 netbox/templates/virtualization/virtualmachine/virtual_disks.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5614310f50f..582db27011b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -59,7 +59,6 @@ def get_children(self, request, parent): class DeviceTypeComponentsView(generic.ObjectChildrenView): actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) queryset = DeviceType.objects.all() - template_name = 'dcim/devicetype/component_templates.html' viewname = None # Used for return_url resolution def get_children(self, request, parent): @@ -74,7 +73,6 @@ def get_extra_context(self, request, instance): class ModuleTypeComponentsView(generic.ObjectChildrenView): queryset = ModuleType.objects.all() actions = (Edit, Delete, BulkEdit, BulkRename, BulkDelete) - template_name = 'dcim/moduletype/component_templates.html' viewname = None # Used for return_url resolution def get_children(self, request, parent): diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py index 123e64f84c5..2eb99b4c2dc 100644 --- a/netbox/netbox/object_actions.py +++ b/netbox/netbox/object_actions.py @@ -25,11 +25,17 @@ class ObjectAction: url_kwargs = [] @classmethod - def get_context(cls, context, obj): + def get_url(cls, obj): viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}' - url = reverse(viewname, kwargs={kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs}) + kwargs = { + kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs + } + return reverse(viewname, kwargs=kwargs) + + @classmethod + def get_context(cls, context, obj): return { - 'url': url, + 'url': cls.get_url(obj), 'label': cls.label, } diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index f43512b0972..e4d3001363b 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -19,6 +19,7 @@ class ActionsMixin: Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map with custom actions, such as bulk_sync. """ + actions = tuple() # TODO: Remove in NetBox v4.6 @staticmethod diff --git a/netbox/templates/dcim/devicetype/component_templates.html b/netbox/templates/dcim/devicetype/component_templates.html deleted file mode 100644 index 4d136194215..00000000000 --- a/netbox/templates/dcim/devicetype/component_templates.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'generic/object_children.html' %} -{% load helpers %} -{% load i18n %} -{% load perms %} - -{#{% block bulk_edit_controls %}#} -{# {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}#} -{# {% if 'bulk_edit' in actions and bulk_edit_view %}#} -{# #} -{# {% endif %}#} -{# {% endwith %}#} -{# {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}#} -{# {% if 'bulk_rename' in actions and bulk_rename_view %}#} -{# #} -{# {% endif %}#} -{# {% endwith %}#} -{#{% endblock bulk_edit_controls %}#} diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html deleted file mode 100644 index 3cee0bbd9fc..00000000000 --- a/netbox/templates/dcim/moduletype/component_templates.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'generic/object_children.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load i18n %} - -{% block extra_controls %} - {% include 'dcim/inc/moduletype_buttons.html' %} -{% endblock %} - -{% block bulk_edit_controls %} - {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} - {% if 'bulk_edit' in actions and bulk_edit_view %} - - {% endif %} - {% endwith %} - {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} - {% if 'bulk_rename' in actions and bulk_rename_view %} - - {% endif %} - {% endwith %} -{% endblock bulk_edit_controls %} - diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index 93d7323e4d6..fe68d423165 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -40,36 +40,7 @@ {% for name, action in actions.items %} {% bulk_action_button action model %} {% endfor %} -{#
#} - {# Bulk edit buttons #} -{# {% block bulk_edit_controls %}#} -{# {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}#} -{# {% if 'bulk_edit' in actions and bulk_edit_view %}#} -{# #} -{# {% endif %}#} -{# {% endwith %}#} -{# {% endblock bulk_edit_controls %}#} -{#
#} -{#
#} - {# Bulk delete buttons #} -{# {% block bulk_delete_controls %}#} -{# {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}#} -{# {% if 'bulk_delete' in actions and bulk_delete_view %}#} -{# #} -{# {% endif %}#} -{# {% endwith %}#} -{# {% endblock bulk_delete_controls %}#} -{#
#} - {# Other bulk action buttons #} - {% block bulk_extra_controls %}{% endblock %} + {% block bulk_extra_controls %}{% endblock %} {% endblock bulk_controls %} diff --git a/netbox/templates/virtualization/virtualmachine/virtual_disks.html b/netbox/templates/virtualization/virtualmachine/virtual_disks.html deleted file mode 100644 index 2637d851a1e..00000000000 --- a/netbox/templates/virtualization/virtualmachine/virtual_disks.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'generic/object_children.html' %} -{% load helpers %} -{% load i18n %} - -{% block bulk_edit_controls %} - {{ block.super }} - {% if 'bulk_rename' in actions %} - - {% endif %} -{% endblock bulk_edit_controls %} From 1c558805a544781b1290e82a7e6b77644e8d4986 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 26 Jun 2025 21:46:36 -0400 Subject: [PATCH 05/20] Fix has_bulk_actions --- netbox/netbox/views/generic/bulk_views.py | 3 +-- netbox/netbox/views/generic/object_views.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 548e945d1ad..69c0046aa79 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -152,8 +152,7 @@ def get(self, request): # Determine the available actions actions = self.get_permitted_actions(request.user) - # has_bulk_actions = any([a.startswith('bulk_') for a in actions]) - has_bulk_actions = True + has_bulk_actions = any(action.bulk for action in actions.values()) if 'export' in request.GET: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 7ac2e7b113a..bfd9d1143bf 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,8 +143,7 @@ def get(self, request, *args, **kwargs): # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) - # has_bulk_actions = any([a.startswith('bulk_') for a in actions]) - has_bulk_actions = True + has_bulk_actions = any(a.bulk for a in actions) table_data = self.prep_table_data(request, child_objects, instance) table = self.get_table(table_data, request, has_bulk_actions) From faa0d859bba7e4fb49f8662b48770bd0ca59e9a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jun 2025 13:36:50 -0400 Subject: [PATCH 06/20] Fix has_bulk_actions for ObjectChildrenView --- netbox/netbox/views/generic/object_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index bfd9d1143bf..ba3ad252efa 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,7 +143,7 @@ def get(self, request, *args, **kwargs): # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) - has_bulk_actions = any(a.bulk for a in actions) + has_bulk_actions = any(action.bulk for action in actions.values()) table_data = self.prep_table_data(request, child_objects, instance) table = self.get_table(table_data, request, has_bulk_actions) From ce8c1490c841ab1979b6b32f1747953f6250421a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jun 2025 13:42:04 -0400 Subject: [PATCH 07/20] Restore clone button --- netbox/templates/generic/object.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 67a43471639..4851dc3e645 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -80,6 +80,9 @@ {% if perms.extras.add_subscription and object.subscriptions %} {% subscribe_button object %} {% endif %} + {% if request.user|can_add:object %} + {% clone_button object %} + {% endif %} {% for name, action in actions.items %} {% action_button action object %} {% endfor %} From 2be2cdb215cd630593f5bf2ae76a2280f5276dbd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jun 2025 14:15:45 -0400 Subject: [PATCH 08/20] Misc cleanup --- netbox/core/views.py | 5 ++++- netbox/dcim/views.py | 1 + netbox/templates/core/datafile.html | 6 ------ netbox/templates/core/job.html | 6 ------ netbox/templates/dcim/device_list.html | 16 +++++----------- netbox/templates/dcim/virtualchassis.html | 10 ---------- 6 files changed, 10 insertions(+), 34 deletions(-) diff --git a/netbox/core/views.py b/netbox/core/views.py index c766fdf80d0..3e9f9ff9df2 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -22,7 +22,7 @@ from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job from netbox.config import get_config, PARAMS -from netbox.object_actions import BulkDelete, BulkExport +from netbox.object_actions import Add, BulkDelete, BulkExport, Delete from netbox.registry import registry from netbox.views import generic from netbox.views.generic.base import BaseObjectView @@ -145,6 +145,7 @@ class DataFileListView(generic.ObjectListView): @register_model_view(DataFile) class DataFileView(generic.ObjectView): queryset = DataFile.objects.all() + actions = (Delete,) @register_model_view(DataFile, 'delete') @@ -175,6 +176,7 @@ class JobListView(generic.ObjectListView): @register_model_view(Job) class JobView(generic.ObjectView): queryset = Job.objects.all() + actions = (Delete,) @register_model_view(Job, 'delete') @@ -268,6 +270,7 @@ class ConfigRevisionListView(generic.ObjectListView): filterset = filtersets.ConfigRevisionFilterSet filterset_form = forms.ConfigRevisionFilterForm table = tables.ConfigRevisionTable + actions = (Add, BulkExport) @register_model_view(ConfigRevision) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 582db27011b..4c40323f2b8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2108,6 +2108,7 @@ class DeviceListView(generic.ObjectListView): filterset = filtersets.DeviceFilterSet filterset_form = forms.DeviceFilterForm table = tables.DeviceTable + actions = (Add, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete) template_name = 'dcim/device_list.html' diff --git a/netbox/templates/core/datafile.html b/netbox/templates/core/datafile.html index 175a0e2bcb4..0747547b1c1 100644 --- a/netbox/templates/core/datafile.html +++ b/netbox/templates/core/datafile.html @@ -11,12 +11,6 @@ {% endblock %} -{% block control-buttons %} - {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} -{% endblock control-buttons %} - {% block content %}
diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index a38c3650a51..49fa0231a94 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -22,12 +22,6 @@ {% endif %} {% endblock breadcrumbs %} -{% block control-buttons %} - {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} -{% endblock control-buttons %} - {% block content %}
diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 493b652f58e..a8b8a91c060 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -75,15 +75,9 @@
{% endif %} - {% 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 %} +
+ {% for name, action in actions.items %} + {% bulk_action_button action model %} + {% endfor %} +
{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index cce005ed16c..da5a812a2c3 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -1,18 +1,8 @@ {% extends 'generic/object.html' %} -{% load buttons %} {% load helpers %} {% load plugins %} {% load i18n %} -{% block buttons %} - {% if perms.dcim.change_virtualchassis %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_virtualchassis %} - {% delete_button object %} - {% endif %} -{% endblock %} - {% block content %}
From 60d80621edea5bcd0ff1a645433035e651e9ea03 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jun 2025 14:31:27 -0400 Subject: [PATCH 09/20] Clean up custom bulk actions --- netbox/extras/views.py | 1 - netbox/templates/dcim/device_list.html | 12 +++--------- .../templates/extras/configcontext_list.html | 11 ----------- netbox/templates/generic/object_list.html | 19 ++++++++++++------- .../virtualization/virtualmachine_list.html | 7 +++---- 5 files changed, 18 insertions(+), 32 deletions(-) delete mode 100644 netbox/templates/extras/configcontext_list.html diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 7f274491bce..5a733c19432 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -753,7 +753,6 @@ class ConfigContextListView(generic.ObjectListView): filterset = filtersets.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm table = tables.ConfigContextTable - template_name = 'extras/configcontext_list.html' actions = (Add, BulkSync, BulkEdit, BulkDelete) diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index a8b8a91c060..ca451105808 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,10 +1,9 @@ {% extends 'generic/object_list.html' %} -{% load buttons %} {% load i18n %} -{% block bulk_buttons %} +{% block extra_bulk_buttons %} {% if perms.dcim.change_device %} -
@@ -118,13 +112,8 @@
{% block bulk_buttons %}
- {# Extra bulk buttons #} {% block extra_bulk_buttons %}{% endblock %} - - {# Default bulk action buttons #} - {% for name, action in actions.items %} - {% bulk_action_button action model %} - {% endfor %} + {% action_buttons actions model bulk=True %}
{% endblock %}
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 37e1e514fa3..3a19ccf62d9 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.template import loader from django.urls import NoReverseMatch, reverse +from django.utils.safestring import mark_safe from core.models import ObjectType from extras.models import Bookmark, ExportTemplate, Subscription @@ -10,10 +11,9 @@ from utilities.views import get_viewname __all__ = ( - 'action_button', + 'action_buttons', 'add_button', 'bookmark_button', - 'bulk_action_button', 'bulk_delete_button', 'bulk_edit_button', 'clone_button', @@ -28,8 +28,17 @@ register = template.Library() +@register.simple_tag(takes_context=True) +def action_buttons(context, actions, obj, bulk=False): + buttons = [ + loader.render_to_string(action.template_name, action.get_context(context, obj)) + for action in actions if action.bulk == bulk + ] + return mark_safe(''.join(buttons)) + + # -# Instance buttons +# Legacy object buttons # @register.inclusion_tag('buttons/bookmark.html', takes_context=True) @@ -145,7 +154,7 @@ def sync_button(instance): # -# List buttons +# Legacy list buttons # @register.inclusion_tag('buttons/add.html') @@ -220,17 +229,3 @@ def bulk_delete_button(context, model, action='bulk_delete', query_params=None): 'htmx_navigation': context.get('htmx_navigation'), 'url': url, } - - -@register.simple_tag(takes_context=True) -def action_button(context, action, obj): - if action.bulk: - return '' - return loader.render_to_string(action.template_name, action.get_context(context, obj)) - - -@register.simple_tag(takes_context=True) -def bulk_action_button(context, action, model): - if not action.bulk: - return '' - return loader.render_to_string(action.template_name, action.get_context(context, model)) From 759ac64a1baf9bb46b1900f3772786f3f3c04d5b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jun 2025 15:38:35 -0400 Subject: [PATCH 12/20] Fix support for legacy action dicts --- netbox/netbox/views/generic/mixins.py | 52 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 5502e2447c4..079164ed9aa 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -9,6 +9,18 @@ 'TableMixin', ) +# TODO: Remove in NetBox v4.5 +LEGACY_ACTIONS = { + 'add': object_actions.AddObject, + 'edit': object_actions.EditObject, + 'delete': object_actions.DeleteObject, + 'export': object_actions.BulkExport, + 'bulk_import': object_actions.BulkImport, + 'bulk_edit': object_actions.BulkEdit, + 'bulk_rename': object_actions.BulkRename, + 'bulk_delete': object_actions.BulkDelete, +} + class ActionsMixin: """ @@ -21,26 +33,22 @@ class ActionsMixin: """ actions = tuple() - # TODO: Remove in NetBox v4.6 - @staticmethod - def _get_legacy_action(name): + # TODO: Remove in NetBox v4.5 + def _convert_legacy_actions(self): """ - Given a legacy action name, return the corresponding action class. + Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses. """ - action = { - 'add': object_actions.AddObject, - 'edit': object_actions.EditObject, - 'delete': object_actions.DeleteObject, - 'export': object_actions.BulkExport, - 'bulk_import': object_actions.BulkImport, - 'bulk_edit': object_actions.BulkEdit, - 'bulk_rename': object_actions.BulkRename, - 'bulk_delete': object_actions.BulkDelete, - }.get(name) - if name is None: - raise ValueError(f"Unknown action: {action}") - - return action + if type(self.actions) is not dict: + return + + actions = [] + for name in self.actions.keys(): + try: + actions.append(LEGACY_ACTIONS[name]) + except KeyError: + raise ValueError(f"Unsupported legacy action: {name}") + + self.actions = actions def get_permitted_actions(self, user, model=None): """ @@ -48,13 +56,15 @@ def get_permitted_actions(self, user, model=None): """ model = model or self.queryset.model + # TODO: Remove in NetBox v4.5 + # Handle legacy action sets + self._convert_legacy_actions() + # Resolve required permissions for each action permitted_actions = [] for action in self.actions: - # Backward compatibility - perms = self._get_legacy_action(action) if type(action) is str else action.permissions_required required_permissions = [ - get_permission_for_model(model, perm) for perm in perms + get_permission_for_model(model, perm) for perm in action.permissions_required ] if not required_permissions or user.has_perms(required_permissions): permitted_actions.append(action) From 0da5000a2c5102f6c107832797a76505fb99499c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jun 2025 09:10:28 -0400 Subject: [PATCH 13/20] Rename bulk attr to multi --- netbox/core/object_actions.py | 2 +- netbox/dcim/object_actions.py | 2 +- netbox/netbox/object_actions.py | 18 ++++++++++++++---- netbox/netbox/views/generic/bulk_views.py | 8 ++++---- netbox/netbox/views/generic/object_views.py | 4 ++-- netbox/templates/generic/object_children.html | 2 +- netbox/templates/generic/object_list.html | 4 ++-- netbox/utilities/templatetags/buttons.py | 4 ++-- 8 files changed, 27 insertions(+), 17 deletions(-) diff --git a/netbox/core/object_actions.py b/netbox/core/object_actions.py index b650037642c..fc538f2053c 100644 --- a/netbox/core/object_actions.py +++ b/netbox/core/object_actions.py @@ -13,6 +13,6 @@ class BulkSync(ObjectAction): """ name = 'bulk_sync' label = _('Sync Data') - bulk = True + multi = True permissions_required = {'sync'} template_name = 'buttons/bulk_sync.html' diff --git a/netbox/dcim/object_actions.py b/netbox/dcim/object_actions.py index d9a124733d0..2b924e86913 100644 --- a/netbox/dcim/object_actions.py +++ b/netbox/dcim/object_actions.py @@ -13,6 +13,6 @@ class BulkDisconnect(ObjectAction): """ name = 'bulk_disconnect' label = _('Disconnect Selected') - bulk = True + multi = True permissions_required = {'change'} template_name = 'buttons/bulk_disconnect.html' diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py index 1a72e124f88..3475a862d79 100644 --- a/netbox/netbox/object_actions.py +++ b/netbox/netbox/object_actions.py @@ -18,9 +18,19 @@ class ObjectAction: + """ + Base class for single- and multi-object operations. + + Params: + name: The action name + label: Human-friendly label for the rendered button + multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table) + permissions_required: The set of permissions a user must have to perform the action + url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL + """ name = '' label = None - bulk = False + multi = False permissions_required = set() url_kwargs = [] @@ -117,7 +127,7 @@ class BulkEdit(ObjectAction): """ name = 'bulk_edit' label = _('Edit Selected') - bulk = True + multi = True permissions_required = {'change'} template_name = 'buttons/bulk_edit.html' @@ -128,7 +138,7 @@ class BulkRename(ObjectAction): """ name = 'bulk_rename' label = _('Rename Selected') - bulk = True + multi = True permissions_required = {'change'} template_name = 'buttons/bulk_rename.html' @@ -139,6 +149,6 @@ class BulkDelete(ObjectAction): """ name = 'bulk_delete' label = _('Delete Selected') - bulk = True + multi = True permissions_required = {'delete'} template_name = 'buttons/bulk_delete.html' diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 477a36e9064..08b06063430 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -152,13 +152,13 @@ def get(self, request): # Determine the available actions actions = self.get_permitted_actions(request.user) - has_bulk_actions = any(action.bulk for action in actions) + has_table_actions = any(action.multi for action in actions) if 'export' in request.GET: # Export the current table view if request.GET['export'] == 'table': - table = self.get_table(self.queryset, request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_table_actions) columns = [name for name, _ in table.selected_columns] return self.export_table(table, columns) @@ -176,11 +176,11 @@ def get(self, request): # Fall back to default table/YAML export else: - table = self.get_table(self.queryset, request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_table_actions) return self.export_table(table) # Render the objects table - table = self.get_table(self.queryset, request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_table_actions) # If this is an HTMX request, return only the rendered table HTML if htmx_partial(request): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 989e1f7a993..67517b38bcf 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,10 +143,10 @@ def get(self, request, *args, **kwargs): # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) - has_bulk_actions = any(action.bulk for action in actions) + has_table_actions = any(action.multi for action in actions) table_data = self.prep_table_data(request, child_objects, instance) - table = self.get_table(table_data, request, has_bulk_actions) + table = self.get_table(table_data, request, has_table_actions) # If this is an HTMX request, return only the rendered table HTML if htmx_partial(request): diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index ff422a06f8a..a95f6ed0fa8 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -37,7 +37,7 @@
{% block bulk_controls %} - {% action_buttons actions model bulk=True %} + {% action_buttons actions model multi=True %} {% block bulk_extra_controls %}{% endblock %} {% endblock bulk_controls %}
diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index a9aa0f678fe..8bce753409a 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -84,7 +84,7 @@
- {% action_buttons actions model bulk=True %} + {% action_buttons actions model multi=True %}
@@ -113,7 +113,7 @@ {% block bulk_buttons %}
{% block extra_bulk_buttons %}{% endblock %} - {% action_buttons actions model bulk=True %} + {% action_buttons actions model multi=True %}
{% endblock %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 3a19ccf62d9..c1003d2490c 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -29,10 +29,10 @@ @register.simple_tag(takes_context=True) -def action_buttons(context, actions, obj, bulk=False): +def action_buttons(context, actions, obj, multi=False): buttons = [ loader.render_to_string(action.template_name, action.get_context(context, obj)) - for action in actions if action.bulk == bulk + for action in actions if action.multi == multi ] return mark_safe(''.join(buttons)) From 87e910421cbd2d830f57c12510d2363ffd4c74c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jun 2025 09:25:38 -0400 Subject: [PATCH 14/20] clone_button tag should fail silently if view name is invalid --- netbox/utilities/templatetags/buttons.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index c1003d2490c..977ad8fd242 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -74,17 +74,19 @@ def bookmark_button(context, instance): @register.inclusion_tag('buttons/clone.html') def clone_button(instance): - url = reverse(get_viewname(instance, 'add')) + # Resolve URL path + viewname = get_viewname(instance, 'add') + try: + url = reverse(viewname) + except NoReverseMatch: + return { + 'url': None, + } - # Populate cloned field values + # Populate cloned field values and return full URL param_string = prepare_cloned_fields(instance).urlencode() - if param_string: - url = f'{url}?{param_string}' - else: - url = None - return { - 'url': url, + 'url': f'{url}?{param_string}' if param_string else None, } From 12958e9653769211353e911ba5933dd988e444b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jun 2025 09:56:18 -0400 Subject: [PATCH 15/20] Clean up action buttons --- netbox/templates/generic/object_children.html | 2 - netbox/utilities/templates/buttons/add.html | 9 +-- .../templates/buttons/bulk_delete.html | 9 +-- .../templates/buttons/bulk_disconnect.html | 9 +-- .../templates/buttons/bulk_edit.html | 9 +-- .../templates/buttons/bulk_rename.html | 9 +-- .../templates/buttons/bulk_sync.html | 9 +-- .../utilities/templates/buttons/delete.html | 4 +- netbox/utilities/templates/buttons/edit.html | 3 +- .../utilities/templates/buttons/export.html | 2 +- .../utilities/templates/buttons/import.html | 9 +-- netbox/utilities/templates/buttons/sync.html | 3 +- netbox/utilities/templatetags/buttons.py | 69 ++++++++++++------- 13 files changed, 69 insertions(+), 77 deletions(-) diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index a95f6ed0fa8..b9eabdc9d38 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -8,8 +8,6 @@ - content: Primary page content - table_controls: Control elements for the child objects table - bulk_controls: Bulk action buttons which appear beneath the child objects table - - bulk_edit_controls: Bulk edit buttons - - bulk_delete_controls: Bulk delete buttons - bulk_extra_controls: Other bulk action buttons - modals: Any pre-loaded modals diff --git a/netbox/utilities/templates/buttons/add.html b/netbox/utilities/templates/buttons/add.html index df257b5d261..cf14769d34b 100644 --- a/netbox/utilities/templates/buttons/add.html +++ b/netbox/utilities/templates/buttons/add.html @@ -1,6 +1,3 @@ -{% if url %} -{% load i18n %} - - {% trans "Add" %} - -{% endif %} + + {{ label }} + diff --git a/netbox/utilities/templates/buttons/bulk_delete.html b/netbox/utilities/templates/buttons/bulk_delete.html index a4860ce2d6a..42dd7ce30ee 100644 --- a/netbox/utilities/templates/buttons/bulk_delete.html +++ b/netbox/utilities/templates/buttons/bulk_delete.html @@ -1,6 +1,3 @@ -{% load i18n %} -{% if url %} - -{% endif %} + diff --git a/netbox/utilities/templates/buttons/bulk_disconnect.html b/netbox/utilities/templates/buttons/bulk_disconnect.html index d5e6e6cd761..9ab53472b76 100644 --- a/netbox/utilities/templates/buttons/bulk_disconnect.html +++ b/netbox/utilities/templates/buttons/bulk_disconnect.html @@ -1,6 +1,3 @@ -{% load i18n %} -{% if url %} - -{% endif %} + diff --git a/netbox/utilities/templates/buttons/bulk_edit.html b/netbox/utilities/templates/buttons/bulk_edit.html index a5aa03112b3..bc50d9b6e1e 100644 --- a/netbox/utilities/templates/buttons/bulk_edit.html +++ b/netbox/utilities/templates/buttons/bulk_edit.html @@ -1,6 +1,3 @@ -{% load i18n %} -{% if url %} - -{% endif %} + diff --git a/netbox/utilities/templates/buttons/bulk_rename.html b/netbox/utilities/templates/buttons/bulk_rename.html index aa1588e8d7b..376faa88b11 100644 --- a/netbox/utilities/templates/buttons/bulk_rename.html +++ b/netbox/utilities/templates/buttons/bulk_rename.html @@ -1,6 +1,3 @@ -{% load i18n %} -{% if url %} - -{% endif %} + diff --git a/netbox/utilities/templates/buttons/bulk_sync.html b/netbox/utilities/templates/buttons/bulk_sync.html index 2563f0a95f1..e92ad15df7d 100644 --- a/netbox/utilities/templates/buttons/bulk_sync.html +++ b/netbox/utilities/templates/buttons/bulk_sync.html @@ -1,6 +1,3 @@ -{% load i18n %} -{% if url %} - -{% endif %} + diff --git a/netbox/utilities/templates/buttons/delete.html b/netbox/utilities/templates/buttons/delete.html index e367cfed13a..768f605d073 100644 --- a/netbox/utilities/templates/buttons/delete.html +++ b/netbox/utilities/templates/buttons/delete.html @@ -1,12 +1,12 @@ -{% load i18n %} - {% trans "Delete" %} + {{ label }} diff --git a/netbox/utilities/templates/buttons/edit.html b/netbox/utilities/templates/buttons/edit.html index 9dc9a0b46d2..9890e595237 100644 --- a/netbox/utilities/templates/buttons/edit.html +++ b/netbox/utilities/templates/buttons/edit.html @@ -1,4 +1,3 @@ -{% load i18n %} - {% trans "Edit" %} + {{ label }} diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index 2797572363c..af388ca53ce 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -1,7 +1,7 @@ {% load i18n %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 77163f5153b..404386910d7 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -69,24 +69,6 @@ def bookmark_button(context, instance): } -@register.inclusion_tag('buttons/clone.html') -def clone_button(instance): - # Resolve URL path - viewname = get_viewname(instance, 'add') - try: - url = reverse(viewname) - except NoReverseMatch: - return { - 'url': None, - } - - # Populate cloned field values and return full URL - param_string = prepare_cloned_fields(instance).urlencode() - return { - 'url': f'{url}?{param_string}' if param_string else None, - } - - @register.inclusion_tag('buttons/subscribe.html', takes_context=True) def subscribe_button(context, instance): # Skip for objects which don't support notifications @@ -126,6 +108,25 @@ def subscribe_button(context, instance): # Legacy object buttons # +# TODO: Remove in NetBox v4.6 +@register.inclusion_tag('buttons/clone.html') +def clone_button(instance): + # Resolve URL path + viewname = get_viewname(instance, 'add') + try: + url = reverse(viewname) + except NoReverseMatch: + return { + 'url': None, + } + + # Populate cloned field values and return full URL + param_string = prepare_cloned_fields(instance).urlencode() + return { + 'url': f'{url}?{param_string}' if param_string else None, + } + + # TODO: Remove in NetBox v4.6 @register.inclusion_tag('buttons/edit.html') def edit_button(instance): From 702b08e6b8206dc0c5ac6e1fbf82c44a2296de4c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jun 2025 11:44:12 -0400 Subject: [PATCH 18/20] Create object actions for adding device/VM components --- netbox/dcim/object_actions.py | 22 +++++- netbox/dcim/views.py | 19 +++-- .../dcim/buttons/bulk_add_components.html | 71 +++++++++++++++++ .../dcim}/buttons/bulk_disconnect.html | 0 netbox/templates/dcim/device_list.html | 77 ------------------- netbox/templates/generic/object_list.html | 1 - .../buttons/bulk_add_components.html | 22 ++++++ .../virtualization/virtualmachine_list.html | 28 ------- netbox/virtualization/object_actions.py | 26 +++++++ netbox/virtualization/views.py | 3 +- 10 files changed, 151 insertions(+), 118 deletions(-) create mode 100644 netbox/templates/dcim/buttons/bulk_add_components.html rename netbox/{utilities/templates => templates/dcim}/buttons/bulk_disconnect.html (100%) delete mode 100644 netbox/templates/dcim/device_list.html create mode 100644 netbox/templates/virtualization/buttons/bulk_add_components.html delete mode 100644 netbox/templates/virtualization/virtualmachine_list.html create mode 100644 netbox/virtualization/object_actions.py diff --git a/netbox/dcim/object_actions.py b/netbox/dcim/object_actions.py index 2b924e86913..00a409274c5 100644 --- a/netbox/dcim/object_actions.py +++ b/netbox/dcim/object_actions.py @@ -3,10 +3,30 @@ from netbox.object_actions import ObjectAction __all__ = ( + 'BulkAddComponents', 'BulkDisconnect', ) +class BulkAddComponents(ObjectAction): + """ + Add components to the selected devices. + """ + label = _('Add Components') + multi = True + permissions_required = {'change'} + template_name = 'dcim/buttons/bulk_add_components.html' + + @classmethod + def get_context(cls, context, obj): + return { + 'perms': context.get('perms'), + 'request': context.get('request'), + 'formaction': context.get('formaction'), + 'label': cls.label, + } + + class BulkDisconnect(ObjectAction): """ Disconnect each of a set of objects to which a cable is connected. @@ -15,4 +35,4 @@ class BulkDisconnect(ObjectAction): label = _('Disconnect Selected') multi = True permissions_required = {'change'} - template_name = 'buttons/bulk_disconnect.html' + template_name = 'dcim/buttons/bulk_disconnect.html' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fe2d73c9598..6d72f5c43b3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -34,7 +34,7 @@ from . import filtersets, forms, tables from .choices import DeviceFaceChoices, InterfaceModeChoices from .models import * -from .object_actions import BulkDisconnect +from .object_actions import BulkAddComponents, BulkDisconnect CABLE_TERMINATION_TYPES = { 'dcim.consoleport': ConsolePort, @@ -2108,8 +2108,7 @@ class DeviceListView(generic.ObjectListView): filterset = filtersets.DeviceFilterSet filterset_form = forms.DeviceFilterForm table = tables.DeviceTable - actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete) - template_name = 'dcim/device_list.html' + actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete) @register_model_view(Device) @@ -2150,7 +2149,7 @@ class DeviceConsolePortsView(DeviceComponentsView): table = tables.DeviceConsolePortTable filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Console Ports'), badge=lambda obj: obj.console_port_count, @@ -2166,7 +2165,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView): table = tables.DeviceConsoleServerPortTable filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Console Server Ports'), badge=lambda obj: obj.console_server_port_count, @@ -2182,7 +2181,7 @@ class DevicePowerPortsView(DeviceComponentsView): table = tables.DevicePowerPortTable filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Power Ports'), badge=lambda obj: obj.power_port_count, @@ -2198,7 +2197,7 @@ class DevicePowerOutletsView(DeviceComponentsView): table = tables.DevicePowerOutletTable filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Power Outlets'), badge=lambda obj: obj.power_outlet_count, @@ -2214,7 +2213,7 @@ class DeviceInterfacesView(DeviceComponentsView): table = tables.DeviceInterfaceTable filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) template_name = 'dcim/device/interfaces.html' tab = ViewTab( label=_('Interfaces'), @@ -2237,7 +2236,7 @@ class DeviceFrontPortsView(DeviceComponentsView): table = tables.DeviceFrontPortTable filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Front Ports'), badge=lambda obj: obj.front_port_count, @@ -2253,7 +2252,7 @@ class DeviceRearPortsView(DeviceComponentsView): table = tables.DeviceRearPortTable filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm - actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete, BulkDisconnect) + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Rear Ports'), badge=lambda obj: obj.rear_port_count, diff --git a/netbox/templates/dcim/buttons/bulk_add_components.html b/netbox/templates/dcim/buttons/bulk_add_components.html new file mode 100644 index 00000000000..b5eadeeacff --- /dev/null +++ b/netbox/templates/dcim/buttons/bulk_add_components.html @@ -0,0 +1,71 @@ +{% load i18n %} +
+ + +
diff --git a/netbox/utilities/templates/buttons/bulk_disconnect.html b/netbox/templates/dcim/buttons/bulk_disconnect.html similarity index 100% rename from netbox/utilities/templates/buttons/bulk_disconnect.html rename to netbox/templates/dcim/buttons/bulk_disconnect.html diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html deleted file mode 100644 index ca451105808..00000000000 --- a/netbox/templates/dcim/device_list.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load i18n %} - -{% block extra_bulk_buttons %} - {% if perms.dcim.change_device %} -
- - -
- {% endif %} -{% endblock extra_bulk_buttons %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 8bce753409a..70d9e858d0c 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -112,7 +112,6 @@
{% block bulk_buttons %}
- {% block extra_bulk_buttons %}{% endblock %} {% action_buttons actions model multi=True %}
{% endblock %} diff --git a/netbox/templates/virtualization/buttons/bulk_add_components.html b/netbox/templates/virtualization/buttons/bulk_add_components.html new file mode 100644 index 00000000000..8b050086260 --- /dev/null +++ b/netbox/templates/virtualization/buttons/bulk_add_components.html @@ -0,0 +1,22 @@ +{% load i18n %} +
+ + +
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html deleted file mode 100644 index b126f0c0b6e..00000000000 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load i18n %} - -{% block extra_bulk_buttons %} - {% if perms.virtualization.change_virtualmachine %} -
- - -
- {% endif %} -{% endblock extra_bulk_buttons %} diff --git a/netbox/virtualization/object_actions.py b/netbox/virtualization/object_actions.py new file mode 100644 index 00000000000..0f248b4e43e --- /dev/null +++ b/netbox/virtualization/object_actions.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext as _ + +from netbox.object_actions import ObjectAction + +__all__ = ( + 'BulkAddComponents', +) + + +class BulkAddComponents(ObjectAction): + """ + Add components to the selected virtual machines. + """ + label = _('Add Components') + multi = True + permissions_required = {'change'} + template_name = 'virtualization/buttons/bulk_add_components.html' + + @classmethod + def get_context(cls, context, obj): + return { + 'perms': context.get('perms'), + 'request': context.get('request'), + 'formaction': context.get('formaction'), + 'label': cls.label, + } diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0bfe2c9a439..e6ab075711c 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -20,6 +20,7 @@ from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables from .models import * +from .object_actions import BulkAddComponents # @@ -321,7 +322,7 @@ class VirtualMachineListView(generic.ObjectListView): filterset = filtersets.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineTable - template_name = 'virtualization/virtualmachine_list.html' + actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkDelete) @register_model_view(VirtualMachine) From 8bce9751f496dcffc0eae8eda9adff1c80fea447 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jun 2025 11:47:09 -0400 Subject: [PATCH 19/20] Move core_sync.html to core app --- netbox/core/object_actions.py | 2 +- netbox/extras/views.py | 2 +- .../templates => templates/core}/buttons/bulk_sync.html | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename netbox/{utilities/templates => templates/core}/buttons/bulk_sync.html (100%) diff --git a/netbox/core/object_actions.py b/netbox/core/object_actions.py index fc538f2053c..81b5fb2c838 100644 --- a/netbox/core/object_actions.py +++ b/netbox/core/object_actions.py @@ -15,4 +15,4 @@ class BulkSync(ObjectAction): label = _('Sync Data') multi = True permissions_required = {'sync'} - template_name = 'buttons/bulk_sync.html' + template_name = 'core/buttons/bulk_sync.html' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d5837f86614..7216c4eec32 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1021,7 +1021,7 @@ class JournalEntryListView(generic.ObjectListView): filterset = filtersets.JournalEntryFilterSet filterset_form = forms.JournalEntryFilterForm table = tables.JournalEntryTable - actions = (BulkImport, BulkSync, BulkEdit, BulkDelete) + actions = (BulkImport, BulkEdit, BulkDelete) @register_model_view(JournalEntry) diff --git a/netbox/utilities/templates/buttons/bulk_sync.html b/netbox/templates/core/buttons/bulk_sync.html similarity index 100% rename from netbox/utilities/templates/buttons/bulk_sync.html rename to netbox/templates/core/buttons/bulk_sync.html From 9e63c2c4aba4375375ad8994bad09feb4096bc63 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jun 2025 12:02:42 -0400 Subject: [PATCH 20/20] Remove extra_bulk_buttons from template doc --- netbox/templates/generic/object_list.html | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 70d9e858d0c..6a2c3d4c947 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -8,14 +8,13 @@ {% comment %} Blocks: - - title: Page title - - controls: Control elements displayed between the header and content - - extra_controls: Any additional action buttons to display - - tabs: Page tabs - - content: Primary page content - - bulk_buttons: Bulk action buttons to display beneath the objects list - - extra_bulk_buttons: Addition bulk action buttons - - modals: Any pre-loaded modals + - title: Page title + - controls: Control elements displayed between the header and content + - extra_controls: Any additional action buttons to display + - tabs: Page tabs + - content: Primary page content + - bulk_buttons: Additional bulk action buttons to display beneath the objects list + - modals: Any pre-loaded modals Context: - model: The model class being listed