Initial work on #19735

This commit is contained in:
Jeremy Stretch 2025-06-20 17:19:36 -04:00
parent 71e6ea5785
commit c438c13045
7 changed files with 170 additions and 36 deletions

View File

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

View File

@ -22,6 +22,7 @@ from core.models import ObjectType
from core.signals import clear_events from core.signals import clear_events
from extras.choices import CustomFieldUIEditableChoices from extras.choices import CustomFieldUIEditableChoices
from extras.models import CustomField, ExportTemplate 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.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
@ -60,6 +61,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
template_name = 'generic/object_list.html' template_name = 'generic/object_list.html'
filterset = None filterset = None
filterset_form = None filterset_form = None
actions = (Add, BulkImport, BulkEdit, BulkExport, BulkDelete)
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view') return get_permission_for_model(self.queryset.model, 'view')
@ -150,7 +152,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Determine the available actions # Determine the available actions
actions = self.get_permitted_actions(request.user) 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: if 'export' in request.GET:

View File

@ -1,7 +1,6 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from extras.models import TableConfig from extras.models import TableConfig
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
__all__ = ( __all__ = (
@ -19,7 +18,7 @@ class ActionsMixin:
Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
with custom actions, such as bulk_sync. with custom actions, such as bulk_sync.
""" """
actions = DEFAULT_ACTION_PERMISSIONS # actions = DEFAULT_ACTION_PERMISSIONS
def get_permitted_actions(self, user, model=None): def get_permitted_actions(self, user, model=None):
""" """
@ -30,13 +29,16 @@ class ActionsMixin:
# Resolve required permissions for each action # Resolve required permissions for each action
permitted_actions = [] permitted_actions = []
for action in self.actions: for action in self.actions:
perms = action if type(action) is str else action.permissions_required # Backward compatibility
required_permissions = [ 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): if not required_permissions or user.has_perms(required_permissions):
permitted_actions.append(action) permitted_actions.append(action)
return permitted_actions return {
action.name: action for action in permitted_actions
}
class TableMixin: class TableMixin:

View File

@ -14,6 +14,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.signals import clear_events 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.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.forms import ConfirmationForm, restrict_form_fields
@ -36,7 +37,7 @@ __all__ = (
) )
class ObjectView(BaseObjectView): class ObjectView(ActionsMixin, BaseObjectView):
""" """
Retrieve a single object for display. Retrieve a single object for display.
@ -46,6 +47,7 @@ class ObjectView(BaseObjectView):
tab: A ViewTab instance for the view tab: A ViewTab instance for the view
""" """
tab = None tab = None
actions = (Edit, Delete)
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view') return get_permission_for_model(self.queryset.model, 'view')
@ -72,9 +74,11 @@ class ObjectView(BaseObjectView):
request: The current request request: The current request
""" """
instance = self.get_object(**kwargs) instance = self.get_object(**kwargs)
actions = self.get_permitted_actions(request.user, model=instance)
return render(request, self.get_template_name(), { return render(request, self.get_template_name(), {
'object': instance, 'object': instance,
'actions': actions,
'tab': self.tab, 'tab': self.tab,
**self.get_extra_context(request, instance), **self.get_extra_context(request, instance),
}) })
@ -97,6 +101,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table = None table = None
filterset = None filterset = None
filterset_form = None filterset_form = None
actions = (Add, BulkImport, BulkEdit, BulkExport, BulkDelete)
template_name = 'generic/object_children.html' template_name = 'generic/object_children.html'
def get_children(self, request, parent): def get_children(self, request, parent):

View File

@ -80,15 +80,9 @@ Context:
{% if perms.extras.add_subscription and object.subscriptions %} {% if perms.extras.add_subscription and object.subscriptions %}
{% subscribe_button object %} {% subscribe_button object %}
{% endif %} {% endif %}
{% if request.user|can_add:object %} {% for name, action in actions.items %}
{% clone_button object %} {% action_button action object %}
{% endif %} {% endfor %}
{% if request.user|can_change:object %}
{% edit_button object %}
{% endif %}
{% if request.user|can_delete:object %}
{% delete_button object %}
{% endif %}
{% endblock control-buttons %} {% endblock control-buttons %}
</div> </div>

View File

@ -31,15 +31,11 @@ Context:
<div class="btn-list"> <div class="btn-list">
{% plugin_list_buttons model %} {% plugin_list_buttons model %}
{% block extra_controls %}{% endblock %} {% block extra_controls %}{% endblock %}
{% if 'add' in actions %} {% for name, action in actions.items %}
{% add_button model %} {% if not action.bulk %}
{% endif %} {% action_button action model %}
{% if 'bulk_import' in actions %} {% endif %}
{% import_button model %} {% endfor %}
{% endif %}
{% if 'export' in actions %}
{% export_button model %}
{% endif %}
</div> </div>
{% endblock controls %} {% endblock controls %}
@ -91,12 +87,11 @@ Context:
</label> </label>
</div> </div>
<div class="bulk-action-buttons"> <div class="bulk-action-buttons">
{% if 'bulk_edit' in actions %} {% for name, action in actions.items %}
{% bulk_edit_button model query_params=request.GET %} {% if action.bulk %}
{% endif %} {% bulk_action_button action model %}
{% if 'bulk_delete' in actions %} {% endif %}
{% bulk_delete_button model query_params=request.GET %} {% endfor %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -124,12 +119,11 @@ Context:
<div class="btn-list d-print-none"> <div class="btn-list d-print-none">
{% block bulk_buttons %} {% block bulk_buttons %}
<div class="bulk-action-buttons"> <div class="bulk-action-buttons">
{% if 'bulk_edit' in actions %} {% for name, action in actions.items %}
{% bulk_edit_button model query_params=request.GET %} {% if action.bulk %}
{% endif %} {% bulk_action_button action model %}
{% if 'bulk_delete' in actions %} {% endif %}
{% bulk_delete_button model query_params=request.GET %} {% endfor %}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
</div> </div>

View File

@ -1,5 +1,6 @@
from django import template from django import template
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.template import loader
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from core.models import ObjectType from core.models import ObjectType
@ -9,8 +10,10 @@ from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname from utilities.views import get_viewname
__all__ = ( __all__ = (
'action_button',
'add_button', 'add_button',
'bookmark_button', 'bookmark_button',
'bulk_action_button',
'bulk_delete_button', 'bulk_delete_button',
'bulk_edit_button', 'bulk_edit_button',
'clone_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'), 'htmx_navigation': context.get('htmx_navigation'),
'url': url, '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))