diff --git a/netbox/core/object_actions.py b/netbox/core/object_actions.py new file mode 100644 index 000000000..b65003764 --- /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 ef52147f1..c766fdf80 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry 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 000000000..d9a124733 --- /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 304438698..5614310f5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination 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 wireless.models import WirelessLAN 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 @@ CABLE_TERMINATION_TYPES = { 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 @@ class DeviceComponentsView(generic.ObjectChildrenView): 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 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView): } -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 ea465a4a4..7f274491b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,12 +14,13 @@ from jinja2.exceptions import TemplateError 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 8d20fed45..f088c8e4a 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = { '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 0e8f91155..123e64f84 100644 --- a/netbox/netbox/object_actions.py +++ b/netbox/netbox/object_actions.py @@ -10,6 +10,7 @@ __all__ = ( '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 1576e127e..f43512b09 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 @@ class ActionsMixin: # 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 f33b0ba6c..7ac2e7b11 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,7 +143,8 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): # 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 6f91aff3e..000000000 --- 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 %} -