From b47e956a2f4de243e19ae46ef726239284c6bbfe Mon Sep 17 00:00:00 2001 From: crccheck Date: Wed, 13 Jan 2016 22:45:47 -0600 Subject: [PATCH 01/16] pep8 --- example_project/polls/admin.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index 22c08fa..29c305d 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -5,8 +5,8 @@ from django.db.models import F from django.http import HttpResponseRedirect -from django_object_actions import (DjangoObjectActions, - takes_instance_or_queryset) +from django_object_actions import ( + DjangoObjectActions, takes_instance_or_queryset) from .models import Choice, Poll, Comment @@ -42,8 +42,10 @@ def edit_poll(self, request, obj): def raise_key_error(self, request, obj): raise KeyError - objectactions = ('increment_vote', 'decrement_vote', 'reset_vote', - 'edit_poll', 'raise_key_error') + objectactions = ( + 'increment_vote', 'decrement_vote', 'reset_vote', 'edit_poll', + 'raise_key_error', + ) actions = ['increment_vote'] admin.site.register(Choice, ChoiceAdmin) @@ -57,7 +59,8 @@ class ChoiceInline(admin.StackedInline): class PollAdmin(DjangoObjectActions, admin.ModelAdmin): fieldsets = [ (None, {'fields': ['question']}), - ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}), + ('Date information', + {'fields': ['pub_date'], 'classes': ['collapse']}), ] inlines = [ChoiceInline] list_display = ('question', 'pub_date', 'was_published_recently') @@ -74,7 +77,8 @@ def delete_all_choices(self, request, obj): return self.message_user(request, 'All choices deleted') - return render_to_response('clear_choices.html', + return render_to_response( + 'clear_choices.html', dict(object=obj), context_instance=RequestContext(request)) delete_all_choices.label = "Delete All Choices" From 888725e8b35c8484244fec84c1ca83e292e8ad30 Mon Sep 17 00:00:00 2001 From: crccheck Date: Wed, 13 Jan 2016 23:08:54 -0600 Subject: [PATCH 02/16] use a more standard hook for extra context --- .../django_object_actions/change_list.html | 17 ++++++++++ django_object_actions/utils.py | 33 ++++++++++++------- 2 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 django_object_actions/templates/django_object_actions/change_list.html diff --git a/django_object_actions/templates/django_object_actions/change_list.html b/django_object_actions/templates/django_object_actions/change_list.html new file mode 100644 index 0000000..3c8a4ae --- /dev/null +++ b/django_object_actions/templates/django_object_actions/change_list.html @@ -0,0 +1,17 @@ +{% extends "admin/change_list.html" %} + + +{% block object-tools-items %} + {% for tool in objectactions %} +
  • + + {{ tool.label|capfirst }} + +
  • + {% endfor %} + {{ block.super }} +{% endblock %} diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index b1dcfe6..0e67ade 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -69,8 +69,16 @@ def get_urls(self): urls = super(BaseDjangoObjectActions, self).get_urls() return self.get_tool_urls() + urls - def render_change_form(self, request, context, **kwargs): + def changelist_view(self, request, extra_context=None): """Put `objectactions` into the context.""" + extra_context = { + 'objectactions': [], + } + return super(BaseDjangoObjectActions, self).changelist_view( + request, extra_context) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """Add `objectactions` into the context.""" def to_dict(tool_name): """To represents the tool func as a dict with extra meta.""" @@ -83,27 +91,29 @@ def to_dict(tool_name): custom_attrs=custom_attrs, ) - context['objectactions'] = map( - to_dict, - self.get_object_actions(request, context, **kwargs) - ) - context['tools_view_name'] = self.tools_view_name - return super(BaseDjangoObjectActions, self).render_change_form( - request, context, **kwargs) + extra_context = { + 'objectactions': [ + to_dict(action) for action in + self.get_objectactions(request, object_id, form_url) + ], + 'tools_view_name': self.tools_view_name, + } + return super(BaseDjangoObjectActions, self).change_view( + request, object_id, form_url, extra_context) # CUSTOM METHODS ################ - def get_object_actions(self, request, context, **kwargs): + def get_objectactions(self, request, object_id, form_url): """ Override this method to customize what actions get sent. For example, to restrict actions to superusers, you could do: class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin): - def get_object_actions(self, request, context, **kwargs): + def get_objectactions(self, request, context, **kwargs): if request.user.is_superuser: - return super(ChoiceAdmin, self).get_object_actions( + return super(ChoiceAdmin, self).get_objectactions( request, context, **kwargs ) return [] @@ -144,6 +154,7 @@ def get_djoa_button_attrs(self, tool): class DjangoObjectActions(BaseDjangoObjectActions): change_form_template = "django_object_actions/change_form.html" + change_list_template = "django_object_actions/change_list.html" class ModelToolsView(SingleObjectMixin, View): From 7976c96f4dc1035cd2066d39c58a6597092f52db Mon Sep 17 00:00:00 2001 From: crccheck Date: Fri, 19 Feb 2016 21:39:38 -0600 Subject: [PATCH 03/16] use a better place to get into the template context render_change_form works, but change_view is documented --- README.rst | 46 +++++++++++------------ django_object_actions/tests/test_utils.py | 16 ++++---- django_object_actions/tests/tests.py | 11 ------ django_object_actions/utils.py | 18 +++++---- 4 files changed, 41 insertions(+), 50 deletions(-) diff --git a/README.rst b/README.rst index 85b85ad..6fe34c0 100644 --- a/README.rst +++ b/README.rst @@ -119,30 +119,28 @@ by adding a Django widget style `attrs` attribute:: Programmatically Enabling Object Admin Actions `````````````````````````````````````````````` -You can programatically enable and disable registered object actions by defining -your own custom ``get_object_actions()`` method. In this example, certain actions -only apply to certain object states (i.e. You should not be able to close an company -account if the account is already closed):: - - def get_object_actions(self, request, context, **kwargs): - objectactions = [] - - # Actions cannot be applied to new objects (i.e. Using "add" new obj) - if context.get('original') is not None: - # The obj to perform checks against to determine object actions you want to support - obj = context['original'] - - if not obj.verified: - objectactions.extend(['verify_company_account_action', ]) - - status_code = obj.status_code - - if status_code == 'Active': - objectactions.extend(['suspend_company_account_action', 'close_company_account_action', ]) - elif status_code == 'Suspended': - objectactions.extend(['close_company_account_action', 'reactivate_company_account_action', ]) - elif status_code == 'Closed': - objectactions.extend(['reactivate_company_account_action', ]) +You can programmatically enable and disable registered object actions by +defining your own custom ``get_object_actions()`` method. In this example, +certain actions only apply to certain object states (i.e. You should not be +able to close an company account if the account is already closed):: + + def get_object_actions(self, request, object_id, **kwargs): + objectactions = super().get_object_actions(request, object_id, **kwargs) + + # The obj to perform checks against to determine object actions you want to support + obj = self.model.get(pk=object_id) + + if not obj.verified: + objectactions.append('verify_company_account_action') + + status_code = obj.status_code + + if status_code == 'Active': + objectactions.extend(['suspend_company_account_action', 'close_company_account_action']) + elif status_code == 'Suspended': + objectactions.extend(['close_company_account_action', 'reactivate_company_account_action']) + elif status_code == 'Closed': + objectactions.append('reactivate_company_account_action') return objectactions diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index 1207864..bf55b64 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -24,17 +24,19 @@ def test_get_tool_urls_trivial_case(self, mock_site): self.assertEqual(urls[0].name, 'app_model_tools') def test_get_object_actions_gets_attribute(self): - mock_objectactions = [] # set to something mutable - mock_request = 'request' - mock_context = 'context' - mock_kwargs = {} + # Set up + mock_objectactions = mock.Mock() self.instance.objectactions = mock_objectactions + + # Test returned_value = self.instance.get_object_actions( - mock_request, mock_context, **mock_kwargs + request=mock.Mock(), + object_id=mock.Mock(), + form_url=mock.Mock(), ) - # assert that `mock_objectactions` was returned + + # Assert self.assertEqual(id(mock_objectactions), id(returned_value)) - # WISHLIST assert get_object_actions was called with right args def test_get_djoa_button_attrs_returns_defaults(self): # TODO: use `mock` diff --git a/django_object_actions/tests/tests.py b/django_object_actions/tests/tests.py index bea4efa..cd4b11b 100644 --- a/django_object_actions/tests/tests.py +++ b/django_object_actions/tests/tests.py @@ -18,17 +18,6 @@ def setUp(self): class AppTests(LoggedInTestCase): fixtures = ['sample_data'] - def test_bare_mixin_works(self): - # hit admin that doesn't have any tools defined, just the mixin - response = self.client.get(reverse('admin:polls_poll_add')) - self.assertEqual(response.status_code, 200) - - def test_configured_mixin_works(self): - # hit admin that does have any tools defined - response = self.client.get(reverse('admin:polls_choice_add')) - self.assertEqual(response.status_code, 200) - self.assertIn('objectactions', response.context_data) - def test_tool_func_gets_executed(self): c = Choice.objects.get(pk=1) votes = c.votes diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index 0e67ade..2e8911d 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -20,7 +20,7 @@ class BaseDjangoObjectActions(object): ---------- model : django.db.models.Model The Django Model these tools work on. This is populated by Django. - objectactions : list + objectactions : list of str Write the names of the callable attributes (methods) of the model admin that can be used as tools. tools_view_name : str @@ -70,7 +70,7 @@ def get_urls(self): return self.get_tool_urls() + urls def changelist_view(self, request, extra_context=None): - """Put `objectactions` into the context.""" + """Put `objectactions` into the changelist context.""" extra_context = { 'objectactions': [], } @@ -78,7 +78,7 @@ def changelist_view(self, request, extra_context=None): request, extra_context) def change_view(self, request, object_id, form_url='', extra_context=None): - """Add `objectactions` into the context.""" + """Add `objectactions` into the change context.""" def to_dict(tool_name): """To represents the tool func as a dict with extra meta.""" @@ -94,7 +94,7 @@ def to_dict(tool_name): extra_context = { 'objectactions': [ to_dict(action) for action in - self.get_objectactions(request, object_id, form_url) + self.get_object_actions(request, object_id, form_url) ], 'tools_view_name': self.tools_view_name, } @@ -104,17 +104,19 @@ def to_dict(tool_name): # CUSTOM METHODS ################ - def get_objectactions(self, request, object_id, form_url): + def get_object_actions(self, request, object_id, form_url): """ Override this method to customize what actions get sent. + This takes the same parameters as `change_view`. + For example, to restrict actions to superusers, you could do: class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin): - def get_objectactions(self, request, context, **kwargs): + def get_object_actions(self, request, **kwargs): if request.user.is_superuser: - return super(ChoiceAdmin, self).get_objectactions( - request, context, **kwargs + return super(ChoiceAdmin, self).get_object_actions( + request, **kwargs ) return [] """ From 22d6a8d02037bac489ee6dedb541581f815181b8 Mon Sep 17 00:00:00 2001 From: crccheck Date: Fri, 19 Feb 2016 22:26:13 -0600 Subject: [PATCH 04/16] flesh out the changelist actions --- django_object_actions/utils.py | 47 ++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index 2e8911d..227d878 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -22,12 +22,16 @@ class BaseDjangoObjectActions(object): The Django Model these tools work on. This is populated by Django. objectactions : list of str Write the names of the callable attributes (methods) of the model admin - that can be used as tools. + that can be used as tools in the change view. + changelist_actions : list of str + Write the names of the callable attributes (methods) of the model admin + that can be used as tools in the changelist view. tools_view_name : str The name of the Django Object Actions admin view, including the 'admin' namespace. Populated by `get_tool_urls`. """ objectactions = [] + changelist_actions = [] tools_view_name = None def get_tool_urls(self): @@ -69,17 +73,8 @@ def get_urls(self): urls = super(BaseDjangoObjectActions, self).get_urls() return self.get_tool_urls() + urls - def changelist_view(self, request, extra_context=None): - """Put `objectactions` into the changelist context.""" - extra_context = { - 'objectactions': [], - } - return super(BaseDjangoObjectActions, self).changelist_view( - request, extra_context) - def change_view(self, request, object_id, form_url='', extra_context=None): """Add `objectactions` into the change context.""" - def to_dict(tool_name): """To represents the tool func as a dict with extra meta.""" tool = getattr(self, tool_name) @@ -101,12 +96,36 @@ def to_dict(tool_name): return super(BaseDjangoObjectActions, self).change_view( request, object_id, form_url, extra_context) + def changelist_view(self, request, extra_context=None): + """Put `objectactions` into the changelist context.""" + # FIXME DRY + def to_dict(tool_name): + """To represents the tool func as a dict with extra meta.""" + tool = getattr(self, tool_name) + standard_attrs, custom_attrs = self.get_djoa_button_attrs(tool) + return dict( + name=tool_name, + label=getattr(tool, 'label', tool_name), + standard_attrs=standard_attrs, + custom_attrs=custom_attrs, + ) + + extra_context = { + 'objectactions': [ + to_dict(action) for action in + self.get_changelist_actions(request) + ], + 'tools_view_name': self.tools_view_name, + } + return super(BaseDjangoObjectActions, self).changelist_view( + request, extra_context) + # CUSTOM METHODS ################ def get_object_actions(self, request, object_id, form_url): """ - Override this method to customize what actions get sent. + Override this to customize what actions get to the change view. This takes the same parameters as `change_view`. @@ -122,6 +141,12 @@ def get_object_actions(self, request, **kwargs): """ return self.objectactions + def get_changelist_actions(self, request): + """ + Override this to customize what actions get to the changelist view. + """ + return self.changelist_actions + def get_djoa_button_attrs(self, tool): """ Get the HTML attributes associated with a tool. From ff89b413bb0ce85b739f885fc3279cab46849a9b Mon Sep 17 00:00:00 2001 From: crccheck Date: Fri, 19 Feb 2016 22:50:29 -0600 Subject: [PATCH 05/16] add changelist button --- README.rst | 28 +++++++++---------- .../django_object_actions/change_list.html | 2 +- django_object_actions/tests/test_utils.py | 2 +- django_object_actions/utils.py | 21 ++++++++++---- example_project/polls/admin.py | 4 +++ 5 files changed, 35 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index 6fe34c0..694a532 100644 --- a/README.rst +++ b/README.rst @@ -116,34 +116,32 @@ by adding a Django widget style `attrs` attribute:: 'class': 'addlink', } -Programmatically Enabling Object Admin Actions +Programmatically Disabling Object Admin Actions `````````````````````````````````````````````` -You can programmatically enable and disable registered object actions by -defining your own custom ``get_object_actions()`` method. In this example, -certain actions only apply to certain object states (i.e. You should not be -able to close an company account if the account is already closed):: +You can programmatically disable registered actions by defining your own custom +``get_object_actions()`` method. In this example, certain actions only apply to +certain object states (i.e. You should not be able to close an company account +if the account is already closed):: def get_object_actions(self, request, object_id, **kwargs): - objectactions = super().get_object_actions(request, object_id, **kwargs) + actions = super().get_object_actions(request, object_id, **kwargs) - # The obj to perform checks against to determine object actions you want to support obj = self.model.get(pk=object_id) - if not obj.verified: - objectactions.append('verify_company_account_action') + if obj.verified: + actions.remove('verify_company_account_action') status_code = obj.status_code if status_code == 'Active': - objectactions.extend(['suspend_company_account_action', 'close_company_account_action']) - elif status_code == 'Suspended': - objectactions.extend(['close_company_account_action', 'reactivate_company_account_action']) - elif status_code == 'Closed': - objectactions.append('reactivate_company_account_action') + actions.remove('enable_account') + else: + actions.remove('disable_account') - return objectactions + return actions +The same is true for changelist actions with ``get_changelist_actions``. Alternate Installation diff --git a/django_object_actions/templates/django_object_actions/change_list.html b/django_object_actions/templates/django_object_actions/change_list.html index 3c8a4ae..a79c7c2 100644 --- a/django_object_actions/templates/django_object_actions/change_list.html +++ b/django_object_actions/templates/django_object_actions/change_list.html @@ -4,7 +4,7 @@ {% block object-tools-items %} {% for tool in objectactions %}
  • - [0-9a-f\-]+)/tools/(?P\w+)/$', self.admin_site.admin_view( # checks permissions ModelToolsView.as_view( model=self.model, tools=tools, - back=change_view, + back=change_view_url_name, ) ), - name=model_tools_url_name) + name=model_tools_url_name), + # changelist + url(r'^tools/(?P\w+)/$', + self.admin_site.admin_view( # checks permissions + ModelToolsView.as_view( + model=self.model, + tools=tools, + back=change_view_url_name, + ) + ), + name=model_tools_url_name), # FIXME ] # EXISTING ADMIN METHODS MODIFIED diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index 29c305d..e7a32ae 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -30,6 +30,9 @@ def decrement_vote(self, request, obj): obj.save() decrement_vote.short_description = "-1" + def delete_all(self): + pass + def reset_vote(self, request, obj): obj.votes = 0 obj.save() @@ -46,6 +49,7 @@ def raise_key_error(self, request, obj): 'increment_vote', 'decrement_vote', 'reset_vote', 'edit_poll', 'raise_key_error', ) + changelist_actions = ('delete_all',) actions = ['increment_vote'] admin.site.register(Choice, ChoiceAdmin) From 594b8718010513da5b4b7945ddf44bb55c9e305b Mon Sep 17 00:00:00 2001 From: crccheck Date: Fri, 19 Feb 2016 23:01:47 -0600 Subject: [PATCH 06/16] wire button to a new view --- django_object_actions/tests/test_admin.py | 18 +++++++ django_object_actions/tests/test_utils.py | 12 +++++ django_object_actions/utils.py | 62 +++++++++++++++-------- example_project/polls/admin.py | 4 +- 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/django_object_actions/tests/test_admin.py b/django_object_actions/tests/test_admin.py index b215a3c..3ee2cee 100644 --- a/django_object_actions/tests/test_admin.py +++ b/django_object_actions/tests/test_admin.py @@ -15,3 +15,21 @@ def test_action_on_a_model_with_uuid_pk_works(self): self.assertIn('-', url) response = self.client.get(url) self.assertEqual(response.status_code, 302) + + +class ChangeTest(LoggedInTestCase): + def test_buttons_load(self): + url = '/admin/polls/choice/' + response = self.client.get(url) + self.assertIn('objectactions', response.context_data) + self.assertIn('Delete_all', response.rendered_content) + + def test_changelist_action_view(self): + url = '/admin/polls/choice/tools/delete_all/' + response = self.client.get(url) + self.assertRedirects(response, '/admin/polls/choice/') + + def test_changelist_nonexistent_action(self): + url = '/admin/polls/choice/tools/xyzzy/' + response = self.client.get(url) + self.assertEqual(response.status_code, 404) diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index fe3545e..36c7201 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -5,6 +5,7 @@ from ..utils import ( BaseDjangoObjectActions, + BaseActionView, takes_instance_or_queryset, ) @@ -77,6 +78,17 @@ def test_get_djoa_button_attrs_custom_attrs_get_partitioned(self): self.assertEqual(custom['nonstandard'], 'wombat') +class BaseActionViewTests(TestCase): + def setUp(self): + super(BaseActionViewTests, self).setUp() + self.view = BaseActionView() + + @mock.patch('django_object_actions.utils.messages') + def test_message_user_proxies_messages(self, mock_messages): + self.view.message_user('request', 'message') + mock_messages.info.assert_called_once_with('request', 'message') + + class DecoratorTest(TestCase): fixtures = ['sample_data'] diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index d9c8795..3b81683 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -11,6 +11,7 @@ from django.http.response import HttpResponseBase from django.views.generic import View from django.views.generic.detail import SingleObjectMixin +from django.views.generic.list import MultipleObjectMixin class BaseDjangoObjectActions(object): @@ -47,7 +48,6 @@ def get_tool_urls(self): model_name = self.model._meta.module_name base_url_name = '%s_%s' % (self.model._meta.app_label, model_name) model_tools_url_name = '%s_tools' % base_url_name - change_view_url_name = 'admin:%s_change' % base_url_name self.tools_view_name = 'admin:' + model_tools_url_name @@ -57,20 +57,20 @@ def get_tool_urls(self): # change, supports pks that are numbers or uuids url(r'^(?P[0-9a-f\-]+)/tools/(?P\w+)/$', self.admin_site.admin_view( # checks permissions - ModelToolsView.as_view( + ChangeActionView.as_view( model=self.model, tools=tools, - back=change_view_url_name, + back='admin:%s_change' % base_url_name, ) ), name=model_tools_url_name), # changelist url(r'^tools/(?P\w+)/$', self.admin_site.admin_view( # checks permissions - ModelToolsView.as_view( + ChangeListActionView.as_view( model=self.model, tools=tools, - back=change_view_url_name, + back='admin:%s_changelist' % base_url_name, ) ), name=model_tools_url_name), # FIXME @@ -195,14 +195,15 @@ class DjangoObjectActions(BaseDjangoObjectActions): change_list_template = "django_object_actions/change_list.html" -class ModelToolsView(SingleObjectMixin, View): +class BaseActionView(View): """ - The view that runs the tool's callable. + The view that runs a change action tool's callable. Attributes ---------- back : str - The urlpattern name to send users back to. Defaults to the change view. + The urlpattern name to send users back to. This is set in + `get_tool_urls`. model : django.db.model.Model The model this tool operates on. tools : dict @@ -212,33 +213,52 @@ class ModelToolsView(SingleObjectMixin, View): model = None tools = None + def message_user(self, request, message): + """ + Mimic Django admin actions's `message_user`. + + Like the second example: + https://docs.djangoproject.com/en/1.9/ref/contrib/admin/actions/#custom-admin-action + """ + messages.info(request, message) + + +class ChangeActionView(SingleObjectMixin, BaseActionView): def get(self, request, **kwargs): - # SingleOjectMixin's `get_object`. Works because the view - # is instantiated with `model` and the urlpattern has `pk`. obj = self.get_object() try: - tool = self.tools[kwargs['tool']] + view = self.tools[kwargs['tool']] except KeyError: - raise Http404(u'Tool does not exist') + raise Http404('Tool does not exist') - ret = tool(request, obj) + ret = view(request, obj) if isinstance(ret, HttpResponseBase): return ret back = reverse(self.back, args=(kwargs['pk'],)) return HttpResponseRedirect(back) - # HACK to allow POST requests too easily + # HACK to allow POST requests too post = get - def message_user(self, request, message): - """ - Mimic Django admin actions's `message_user`. - Like the second example: - https://docs.djangoproject.com/en/1.9/ref/contrib/admin/actions/#custom-admin-action - """ - messages.info(request, message) +class ChangeListActionView(MultipleObjectMixin, BaseActionView): + def get(self, request, **kwargs): + queryset = self.get_queryset() + try: + view = self.tools[kwargs['tool']] + except KeyError: + raise Http404('Tool does not exist') + + ret = view(request, queryset) + if isinstance(ret, HttpResponseBase): + return ret + + back = reverse(self.back) + return HttpResponseRedirect(back) + + # HACK to allow POST requests too + post = get def takes_instance_or_queryset(func): diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index e7a32ae..7e6bbd5 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -30,8 +30,8 @@ def decrement_vote(self, request, obj): obj.save() decrement_vote.short_description = "-1" - def delete_all(self): - pass + def delete_all(self, request, queryset): + self.message_user(request, 'just kidding!') def reset_vote(self, request, obj): obj.votes = 0 From 823a5e29cf6a4fae1f11552424509a8a0c765a34 Mon Sep 17 00:00:00 2001 From: crccheck Date: Fri, 19 Feb 2016 23:51:03 -0600 Subject: [PATCH 07/16] refactor: dry --- django_object_actions/utils.py | 93 ++++++++++++++-------------------- 1 file changed, 39 insertions(+), 54 deletions(-) diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index 3b81683..c8e00a7 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -86,20 +86,9 @@ def get_urls(self): def change_view(self, request, object_id, form_url='', extra_context=None): """Add `objectactions` into the change context.""" - def to_dict(tool_name): - """To represents the tool func as a dict with extra meta.""" - tool = getattr(self, tool_name) - standard_attrs, custom_attrs = self.get_djoa_button_attrs(tool) - return dict( - name=tool_name, - label=getattr(tool, 'label', tool_name), - standard_attrs=standard_attrs, - custom_attrs=custom_attrs, - ) - extra_context = { 'objectactions': [ - to_dict(action) for action in + self.to_dict(action) for action in self.get_object_actions(request, object_id, form_url) ], 'tools_view_name': self.tools_view_name, @@ -109,21 +98,9 @@ def to_dict(tool_name): def changelist_view(self, request, extra_context=None): """Put `objectactions` into the changelist context.""" - # FIXME DRY - def to_dict(tool_name): - """To represents the tool func as a dict with extra meta.""" - tool = getattr(self, tool_name) - standard_attrs, custom_attrs = self.get_djoa_button_attrs(tool) - return dict( - name=tool_name, - label=getattr(tool, 'label', tool_name), - standard_attrs=standard_attrs, - custom_attrs=custom_attrs, - ) - extra_context = { 'objectactions': [ - to_dict(action) for action in + self.to_dict(action) for action in self.get_changelist_actions(request) ], 'tools_view_name': self.tools_view_name, @@ -134,6 +111,17 @@ def to_dict(tool_name): # CUSTOM METHODS ################ + def to_dict(self, tool_name): + """To represents the tool func as a dict with extra meta.""" + tool = getattr(self, tool_name) + standard_attrs, custom_attrs = self.get_djoa_button_attrs(tool) + return dict( + name=tool_name, + label=getattr(tool, 'label', tool_name), + standard_attrs=standard_attrs, + custom_attrs=custom_attrs, + ) + def get_object_actions(self, request, object_id, form_url): """ Override this to customize what actions get to the change view. @@ -213,52 +201,49 @@ class BaseActionView(View): model = None tools = None - def message_user(self, request, message): - """ - Mimic Django admin actions's `message_user`. - - Like the second example: - https://docs.djangoproject.com/en/1.9/ref/contrib/admin/actions/#custom-admin-action - """ - messages.info(request, message) - - -class ChangeActionView(SingleObjectMixin, BaseActionView): def get(self, request, **kwargs): - obj = self.get_object() try: view = self.tools[kwargs['tool']] except KeyError: raise Http404('Tool does not exist') - ret = view(request, obj) + ret = view(request, *self.view_args) if isinstance(ret, HttpResponseBase): return ret - back = reverse(self.back, args=(kwargs['pk'],)) - return HttpResponseRedirect(back) + return HttpResponseRedirect(self.back_url) # HACK to allow POST requests too post = get + def message_user(self, request, message): + """ + Mimic Django admin actions's `message_user`. -class ChangeListActionView(MultipleObjectMixin, BaseActionView): - def get(self, request, **kwargs): - queryset = self.get_queryset() - try: - view = self.tools[kwargs['tool']] - except KeyError: - raise Http404('Tool does not exist') + Like the second example: + https://docs.djangoproject.com/en/1.9/ref/contrib/admin/actions/#custom-admin-action + """ + messages.info(request, message) - ret = view(request, queryset) - if isinstance(ret, HttpResponseBase): - return ret - back = reverse(self.back) - return HttpResponseRedirect(back) +class ChangeActionView(SingleObjectMixin, BaseActionView): + @property + def view_args(self): + return (self.get_object(), ) - # HACK to allow POST requests too - post = get + @property + def back_url(self): + return reverse(self.back, args=(self.kwargs['pk'],)) + + +class ChangeListActionView(MultipleObjectMixin, BaseActionView): + @property + def view_args(self): + return (self.get_queryset(), ) + + @property + def back_url(self): + return reverse(self.back) def takes_instance_or_queryset(func): From 3a519b28a2e5e02fd224f247603e74dd36a4344d Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 09:09:42 -0600 Subject: [PATCH 08/16] stub changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f8d0448 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +##0.8.0 (unreleased) + +Breaking changes: + +.get_objectactions From c0a63b8e58aa46a9341373e9cb32990cafcc71c4 Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 09:29:49 -0600 Subject: [PATCH 09/16] documentation pass --- .coveragerc | 2 + django_object_actions/tests/test_utils.py | 20 +++++----- django_object_actions/utils.py | 46 ++++++++++++++++------- setup.cfg | 5 +++ 4 files changed, 50 insertions(+), 23 deletions(-) create mode 100644 setup.cfg diff --git a/.coveragerc b/.coveragerc index 337f6cb..44bf799 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,9 +2,11 @@ source = django_object_actions omit = */tests/* +branch = True [report] exclude_lines = pragma: no cover __repr__ __unicode__ + raise NotImplementedError diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index 36c7201..80551b9 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -39,42 +39,42 @@ def test_get_object_actions_gets_attribute(self): # Assert self.assertEqual(id(mock_objectactions), id(returned_value)) - def test_get_djoa_button_attrs_returns_defaults(self): + def test__get_tool_button_attrs_returns_defaults(self): # TODO: use `mock` mock_tool = type('mock_tool', (object, ), {}) - attrs, __ = self.instance.get_djoa_button_attrs(mock_tool) + attrs, __ = self.instance._get_tool_button_attrs(mock_tool) self.assertEqual(attrs['class'], '') self.assertEqual(attrs['title'], '') - def test_get_djoa_button_attrs_disallows_href(self): + def test__get_tool_button_attrs_disallows_href(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'href': 'hreeeeef'}, }) - attrs, __ = self.instance.get_djoa_button_attrs(mock_tool) + attrs, __ = self.instance._get_tool_button_attrs(mock_tool) self.assertNotIn('href', attrs) - def test_get_djoa_button_attrs_disallows_title(self): + def test__get_tool_button_attrs_disallows_title(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'title': 'i wanna be a title'}, 'short_description': 'real title', }) - attrs, __ = self.instance.get_djoa_button_attrs(mock_tool) + attrs, __ = self.instance._get_tool_button_attrs(mock_tool) self.assertEqual(attrs['title'], 'real title') - def test_get_djoa_button_attrs_gets_set(self): + def test__get_tool_button_attrs_gets_set(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'class': 'class'}, 'short_description': 'description', }) - attrs, __ = self.instance.get_djoa_button_attrs(mock_tool) + attrs, __ = self.instance._get_tool_button_attrs(mock_tool) self.assertEqual(attrs['class'], 'class') self.assertEqual(attrs['title'], 'description') - def test_get_djoa_button_attrs_custom_attrs_get_partitioned(self): + def test__get_tool_button_attrs_custom_attrs_get_partitioned(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'nonstandard': 'wombat'}, }) - attrs, custom = self.instance.get_djoa_button_attrs(mock_tool) + attrs, custom = self.instance._get_tool_button_attrs(mock_tool) self.assertEqual(custom['nonstandard'], 'wombat') diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index c8e00a7..48ed31c 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -40,13 +40,14 @@ def get_tool_urls(self): """Get the url patterns that route each tool to a special view.""" tools = {} - # Look for the default change view url and use that as a template try: model_name = self.model._meta.model_name - except AttributeError: + except AttributeError: # pragma: no cover # DJANGO15 model_name = self.model._meta.module_name + # e.g.: polls_poll base_url_name = '%s_%s' % (self.model._meta.app_label, model_name) + # e.g.: polls_poll_tools model_tools_url_name = '%s_tools' % base_url_name self.tools_view_name = 'admin:' + model_tools_url_name @@ -73,7 +74,8 @@ def get_tool_urls(self): back='admin:%s_changelist' % base_url_name, ) ), - name=model_tools_url_name), # FIXME + # Dupe name is fine. https://code.djangoproject.com/ticket/14259 + name=model_tools_url_name), ] # EXISTING ADMIN METHODS MODIFIED @@ -85,10 +87,9 @@ def get_urls(self): return self.get_tool_urls() + urls def change_view(self, request, object_id, form_url='', extra_context=None): - """Add `objectactions` into the change context.""" extra_context = { 'objectactions': [ - self.to_dict(action) for action in + self._get_tool_dict(action) for action in self.get_object_actions(request, object_id, form_url) ], 'tools_view_name': self.tools_view_name, @@ -97,10 +98,9 @@ def change_view(self, request, object_id, form_url='', extra_context=None): request, object_id, form_url, extra_context) def changelist_view(self, request, extra_context=None): - """Put `objectactions` into the changelist context.""" extra_context = { 'objectactions': [ - self.to_dict(action) for action in + self._get_tool_dict(action) for action in self.get_changelist_actions(request) ], 'tools_view_name': self.tools_view_name, @@ -111,10 +111,10 @@ def changelist_view(self, request, extra_context=None): # CUSTOM METHODS ################ - def to_dict(self, tool_name): - """To represents the tool func as a dict with extra meta.""" + def _get_tool_dict(self, tool_name): + """Represents the tool as a dict with extra meta.""" tool = getattr(self, tool_name) - standard_attrs, custom_attrs = self.get_djoa_button_attrs(tool) + standard_attrs, custom_attrs = self._get_tool_button_attrs(tool) return dict( name=tool_name, label=getattr(tool, 'label', tool_name), @@ -146,7 +146,7 @@ def get_changelist_actions(self, request): """ return self.changelist_actions - def get_djoa_button_attrs(self, tool): + def _get_tool_button_attrs(self, tool): """ Get the HTML attributes associated with a tool. @@ -191,7 +191,7 @@ class BaseActionView(View): ---------- back : str The urlpattern name to send users back to. This is set in - `get_tool_urls`. + `get_tool_urls` and turned into a url with the `back_url` property. model : django.db.model.Model The model this tool operates on. tools : dict @@ -201,6 +201,26 @@ class BaseActionView(View): model = None tools = None + @property + def view_args(self): + """ + tuple: The argument(s) to send to the action (excluding `request`). + + Change actions are called with `(request, obj)` while changelist + actions are called with `(request, queryset)`. + """ + raise NotImplementedError + + @property + def back_url(self): + """ + str: The url path the action should send the user back to. + + If an action does not return a http response, we automagically send + users back to either the change or the changelist page. + """ + raise NotImplementedError + def get(self, request, **kwargs): try: view = self.tools[kwargs['tool']] @@ -260,7 +280,7 @@ def decorated_function(self, request, queryset): try: # Django >=1.6,<1.8 model = queryset._meta.model - except AttributeError: + except AttributeError: # pragma: no cover # Django <1.6 model = queryset._meta.concrete_model queryset = model.objects.filter(pk=queryset.pk) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1fd2491 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +universal = 1 + +[flake8] +max-line-length = 90 From e300918c65691ae9e251f8655dc1692882142e8d Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 10:44:02 -0600 Subject: [PATCH 10/16] rename get_object_actions since it broke anyways --- CHANGELOG.md | 16 ++++++++++++--- README.rst | 6 +++--- django_object_actions/tests/test_utils.py | 9 ++++----- django_object_actions/utils.py | 24 +++++++++++------------ example_project/polls/admin.py | 12 +++++++++--- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d0448..b4fdabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ -##0.8.0 (unreleased) +0.8.0 (unreleased) +------------------ -Breaking changes: +### Breaking changes -.get_objectactions +* Deleted `get_objectactions(request, context, **kwargs)`. If you used this + before, use `get_change_actions(request, object_id, form_url)` instead. To + get at the original object, instead of `context['original']`, you can use + `self.model.get(pk=object_id)`, `self.get_object(request, object_id)`, etc. + This isn't as convenient as it used to be, but now it uses the officially + documented way to add extra context to admin views. + https://docs.djangoproject.com/en/dev/ref/contrib/admin/#other-methods + +* Renamed `objectactions`. In your admin, instead of defining your actions in + the `objectactions` attribute, use `change_actions`. diff --git a/README.rst b/README.rst index 694a532..35f116c 100644 --- a/README.rst +++ b/README.rst @@ -120,12 +120,12 @@ Programmatically Disabling Object Admin Actions `````````````````````````````````````````````` You can programmatically disable registered actions by defining your own custom -``get_object_actions()`` method. In this example, certain actions only apply to +``get_change_actions()`` method. In this example, certain actions only apply to certain object states (i.e. You should not be able to close an company account if the account is already closed):: - def get_object_actions(self, request, object_id, **kwargs): - actions = super().get_object_actions(request, object_id, **kwargs) + def get_change_actions(self, request, object_id, **kwargs): + actions = super().get_change_actions(request, object_id, **kwargs) obj = self.model.get(pk=object_id) diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index 80551b9..c244905 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -24,20 +24,19 @@ def test_get_tool_urls_trivial_case(self, mock_site): self.assertEqual(len(urls), 2) self.assertEqual(urls[0].name, 'app_model_tools') - def test_get_object_actions_gets_attribute(self): + def test_get_change_actions_gets_attribute(self): # Set up - mock_objectactions = mock.Mock() - self.instance.objectactions = mock_objectactions + self.instance.change_actions = mock.Mock() # Test - returned_value = self.instance.get_object_actions( + returned_value = self.instance.get_change_actions( request=mock.Mock(), object_id=mock.Mock(), form_url=mock.Mock(), ) # Assert - self.assertEqual(id(mock_objectactions), id(returned_value)) + self.assertEqual(id(self.instance.change_actions), id(returned_value)) def test__get_tool_button_attrs_returns_defaults(self): # TODO: use `mock` diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index 48ed31c..af1337c 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -22,17 +22,17 @@ class BaseDjangoObjectActions(object): ---------- model : django.db.models.Model The Django Model these tools work on. This is populated by Django. - objectactions : list of str - Write the names of the callable attributes (methods) of the model admin - that can be used as tools in the change view. + change_actions : list of str + Write the names of the methods of the model admin that can be used as + tools in the change view. changelist_actions : list of str - Write the names of the callable attributes (methods) of the model admin - that can be used as tools in the changelist view. + Write the names of the methods of the model admin that can be used as + tools in the changelist view. tools_view_name : str The name of the Django Object Actions admin view, including the 'admin' namespace. Populated by `get_tool_urls`. """ - objectactions = [] + change_actions = [] changelist_actions = [] tools_view_name = None @@ -52,7 +52,7 @@ def get_tool_urls(self): self.tools_view_name = 'admin:' + model_tools_url_name - for tool in chain(self.objectactions, self.changelist_actions): + for tool in chain(self.change_actions, self.changelist_actions): tools[tool] = getattr(self, tool) return [ # change, supports pks that are numbers or uuids @@ -90,7 +90,7 @@ def change_view(self, request, object_id, form_url='', extra_context=None): extra_context = { 'objectactions': [ self._get_tool_dict(action) for action in - self.get_object_actions(request, object_id, form_url) + self.get_change_actions(request, object_id, form_url) ], 'tools_view_name': self.tools_view_name, } @@ -122,7 +122,7 @@ def _get_tool_dict(self, tool_name): custom_attrs=custom_attrs, ) - def get_object_actions(self, request, object_id, form_url): + def get_change_actions(self, request, object_id, form_url): """ Override this to customize what actions get to the change view. @@ -131,14 +131,14 @@ def get_object_actions(self, request, object_id, form_url): For example, to restrict actions to superusers, you could do: class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin): - def get_object_actions(self, request, **kwargs): + def get_change_actions(self, request, **kwargs): if request.user.is_superuser: - return super(ChoiceAdmin, self).get_object_actions( + return super(ChoiceAdmin, self).get_change_actions( request, **kwargs ) return [] """ - return self.objectactions + return self.change_actions def get_changelist_actions(self, request): """ diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index 7e6bbd5..1c8f68a 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -45,7 +45,7 @@ def edit_poll(self, request, obj): def raise_key_error(self, request, obj): raise KeyError - objectactions = ( + change_actions = ( 'increment_vote', 'decrement_vote', 'reset_vote', 'edit_poll', 'raise_key_error', ) @@ -86,7 +86,13 @@ def delete_all_choices(self, request, obj): dict(object=obj), context_instance=RequestContext(request)) delete_all_choices.label = "Delete All Choices" - objectactions = ('delete_all_choices', ) + change_actions = ('delete_all_choices', ) + + def get_change_actions(self, request, object_id, form_url): + actions = super(PollAdmin, self).get_change_actions(request, object_id, form_url) + import ipdb; ipdb.set_trace() + return actions + admin.site.register(Poll, PollAdmin) @@ -97,5 +103,5 @@ def hodor(self, request, obj): return obj.comment = ' '.join(['hodor' for x in obj.comment.split()]) obj.save() - objectactions = ('hodor', ) + change_actions = ('hodor', ) admin.site.register(Comment, CommentAdmin) From 67796ff2024ad6519566e5650c60fd9905acfcc8 Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 19:14:56 -0600 Subject: [PATCH 11/16] revamp get_change_action example to use real code --- README.rst | 23 +++++++++-------------- django_object_actions/tests/test_admin.py | 21 ++++++++++++++++++++- django_object_actions/utils.py | 2 ++ example_project/polls/admin.py | 16 ++++++++++++++-- example_project/polls/factories.py | 13 +++++++++++++ requirements.txt | 4 ++-- 6 files changed, 60 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index 35f116c..67a74cc 100644 --- a/README.rst +++ b/README.rst @@ -117,27 +117,22 @@ by adding a Django widget style `attrs` attribute:: } Programmatically Disabling Object Admin Actions -`````````````````````````````````````````````` +``````````````````````````````````````````````` You can programmatically disable registered actions by defining your own custom ``get_change_actions()`` method. In this example, certain actions only apply to certain object states (i.e. You should not be able to close an company account if the account is already closed):: - def get_change_actions(self, request, object_id, **kwargs): - actions = super().get_change_actions(request, object_id, **kwargs) + def get_change_actions(self, request, object_id, form_url): + actions = super(PollAdmin, self).get_change_actions(request, object_id, form_url) + actions = list(actions) + if not request.user.is_superuser: + return [] - obj = self.model.get(pk=object_id) - - if obj.verified: - actions.remove('verify_company_account_action') - - status_code = obj.status_code - - if status_code == 'Active': - actions.remove('enable_account') - else: - actions.remove('disable_account') + obj = self.model.objects.get(pk=object_id) + if obj.question.endswith('?'): + actions.remove('question_mark') return actions diff --git a/django_object_actions/tests/test_admin.py b/django_object_actions/tests/test_admin.py index 3ee2cee..f1d187c 100644 --- a/django_object_actions/tests/test_admin.py +++ b/django_object_actions/tests/test_admin.py @@ -3,8 +3,10 @@ """ from __future__ import unicode_literals +from django.core.urlresolvers import reverse + from .tests import LoggedInTestCase -from example_project.polls.factories import CommentFactory +from example_project.polls.factories import CommentFactory, PollFactory class CommentTest(LoggedInTestCase): @@ -33,3 +35,20 @@ def test_changelist_nonexistent_action(self): url = '/admin/polls/choice/tools/xyzzy/' response = self.client.get(url) self.assertEqual(response.status_code, 404) + + def test_get_changelist_can_remove_action(self): + poll = PollFactory.create() + self.assertFalse(poll.question.endswith('?')) + admin_change_url = reverse('admin:polls_poll_change', args=(poll.pk,)) + action_url = '/admin/polls/poll/1/tools/question_mark/' + + # button is in the admin + response = self.client.get(admin_change_url) + self.assertIn(action_url, response.rendered_content) + + response = self.client.get(action_url) # Click on the button + self.assertRedirects(response, admin_change_url) + + # button is not in the admin anymore + response = self.client.get(admin_change_url) + self.assertNotIn(action_url, response.rendered_content) diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index af1337c..bcae028 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -52,6 +52,8 @@ def get_tool_urls(self): self.tools_view_name = 'admin:' + model_tools_url_name + # WISHLIST use get_change_actions and get_changelist_actions + # TODO separate change and changelist actions for tool in chain(self.change_actions, self.changelist_actions): tools[tool] = getattr(self, tool) return [ diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index 1c8f68a..1fbc7b4 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -86,11 +86,23 @@ def delete_all_choices(self, request, obj): dict(object=obj), context_instance=RequestContext(request)) delete_all_choices.label = "Delete All Choices" - change_actions = ('delete_all_choices', ) + def question_mark(self, request, obj): + """Add a question mark.""" + obj.question = obj.question + '?' + obj.save() + + change_actions = ('delete_all_choices', 'question_mark') def get_change_actions(self, request, object_id, form_url): actions = super(PollAdmin, self).get_change_actions(request, object_id, form_url) - import ipdb; ipdb.set_trace() + actions = list(actions) + if not request.user.is_superuser: + return [] + + obj = self.model.objects.get(pk=object_id) + if obj.question.endswith('?'): + actions.remove('question_mark') + return actions admin.site.register(Poll, PollAdmin) diff --git a/example_project/polls/factories.py b/example_project/polls/factories.py index 7c5dfde..e32ba72 100644 --- a/example_project/polls/factories.py +++ b/example_project/polls/factories.py @@ -6,10 +6,15 @@ get_user_model = lambda: User import factory +from factory import faker +from django.utils import timezone from . import models +fake = faker.faker.Factory.create() + + class UserFactory(factory.DjangoModelFactory): class Meta: model = get_user_model() @@ -25,3 +30,11 @@ class Meta: class CommentFactory(factory.DjangoModelFactory): class Meta: model = models.Comment + + +class PollFactory(factory.DjangoModelFactory): + class Meta: + model = models.Poll + + question = factory.LazyAttribute(lambda __: fake.sentence()) + pub_date = factory.LazyAttribute(lambda __: timezone.now()) diff --git a/requirements.txt b/requirements.txt index c9a3408..6b45190 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -dj-database-url==0.3.0 +dj-database-url==0.4.0 django-extensions==1.6.1 -factory-boy==2.6.0 +factory-boy==2.6.1 coverage==4.0.3 mock==1.3.0 From 324255a53e8a9289122298a361c65e1aa61307fc Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 21:15:28 -0600 Subject: [PATCH 12/16] note about security --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 67a74cc..2b48801 100644 --- a/README.rst +++ b/README.rst @@ -164,6 +164,12 @@ Limitations ect_actions/templates/django_object_actions/change_form.html>`_. You can also use ``from django_object_actions import BaseDjangoObjectActions`` instead. +3. Security. This has been written with the assumption that everyone in the + Django admin belongs there. Permissions should be enforced in your own + actions irregardless of what this provides. Better default security is + planned for the future. + + Development ----------- From 94ce625d4fb3e9fc6cfb0b6b8e0db924d64c0ee4 Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 21:26:33 -0600 Subject: [PATCH 13/16] refactor: re-arrangement --- django_object_actions/tests/test_utils.py | 4 +- django_object_actions/utils.py | 125 +++++++++++----------- 2 files changed, 66 insertions(+), 63 deletions(-) diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index c244905..9d20ec4 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -18,8 +18,8 @@ def setUp(self): @mock.patch('django_object_actions.utils.BaseDjangoObjectActions' '.admin_site', create=True) - def test_get_tool_urls_trivial_case(self, mock_site): - urls = self.instance.get_tool_urls() + def test_get_action_urls_trivial_case(self, mock_site): + urls = self.instance._get_action_urls() self.assertEqual(len(urls), 2) self.assertEqual(urls[0].name, 'app_model_tools') diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index bcae028..743194f 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -16,7 +16,7 @@ class BaseDjangoObjectActions(object): """ - ModelAdmin mixin to add object-tools just like adding admin actions. + ModelAdmin mixin to add new actions just like adding admin actions. Attributes ---------- @@ -30,63 +30,19 @@ class BaseDjangoObjectActions(object): tools in the changelist view. tools_view_name : str The name of the Django Object Actions admin view, including the 'admin' - namespace. Populated by `get_tool_urls`. + namespace. Populated by `_get_action_urls`. """ change_actions = [] changelist_actions = [] tools_view_name = None - def get_tool_urls(self): - """Get the url patterns that route each tool to a special view.""" - tools = {} - - try: - model_name = self.model._meta.model_name - except AttributeError: # pragma: no cover - # DJANGO15 - model_name = self.model._meta.module_name - # e.g.: polls_poll - base_url_name = '%s_%s' % (self.model._meta.app_label, model_name) - # e.g.: polls_poll_tools - model_tools_url_name = '%s_tools' % base_url_name - - self.tools_view_name = 'admin:' + model_tools_url_name - - # WISHLIST use get_change_actions and get_changelist_actions - # TODO separate change and changelist actions - for tool in chain(self.change_actions, self.changelist_actions): - tools[tool] = getattr(self, tool) - return [ - # change, supports pks that are numbers or uuids - url(r'^(?P[0-9a-f\-]+)/tools/(?P\w+)/$', - self.admin_site.admin_view( # checks permissions - ChangeActionView.as_view( - model=self.model, - tools=tools, - back='admin:%s_change' % base_url_name, - ) - ), - name=model_tools_url_name), - # changelist - url(r'^tools/(?P\w+)/$', - self.admin_site.admin_view( # checks permissions - ChangeListActionView.as_view( - model=self.model, - tools=tools, - back='admin:%s_changelist' % base_url_name, - ) - ), - # Dupe name is fine. https://code.djangoproject.com/ticket/14259 - name=model_tools_url_name), - ] - # EXISTING ADMIN METHODS MODIFIED ################################# def get_urls(self): """Prepend `get_urls` with our own patterns.""" urls = super(BaseDjangoObjectActions, self).get_urls() - return self.get_tool_urls() + urls + return self._get_action_urls() + urls def change_view(self, request, object_id, form_url='', extra_context=None): extra_context = { @@ -110,19 +66,8 @@ def changelist_view(self, request, extra_context=None): return super(BaseDjangoObjectActions, self).changelist_view( request, extra_context) - # CUSTOM METHODS - ################ - - def _get_tool_dict(self, tool_name): - """Represents the tool as a dict with extra meta.""" - tool = getattr(self, tool_name) - standard_attrs, custom_attrs = self._get_tool_button_attrs(tool) - return dict( - name=tool_name, - label=getattr(tool, 'label', tool_name), - standard_attrs=standard_attrs, - custom_attrs=custom_attrs, - ) + # USER OVERRIDABLE + ################## def get_change_actions(self, request, object_id, form_url): """ @@ -148,6 +93,64 @@ def get_changelist_actions(self, request): """ return self.changelist_actions + # INTERNAL METHODS + ################## + + def _get_action_urls(self): + """Get the url patterns that route each action to a view.""" + tools = {} + + try: + model_name = self.model._meta.model_name + except AttributeError: # pragma: no cover + # DJANGO15 + model_name = self.model._meta.module_name + # e.g.: polls_poll + base_url_name = '%s_%s' % (self.model._meta.app_label, model_name) + # e.g.: polls_poll_tools + model_tools_url_name = '%s_tools' % base_url_name + + self.tools_view_name = 'admin:' + model_tools_url_name + + # WISHLIST use get_change_actions and get_changelist_actions + # TODO separate change and changelist actions + for tool in chain(self.change_actions, self.changelist_actions): + tools[tool] = getattr(self, tool) + return [ + # change, supports pks that are numbers or uuids + url(r'^(?P[0-9a-f\-]+)/tools/(?P\w+)/$', + self.admin_site.admin_view( # checks permissions + ChangeActionView.as_view( + model=self.model, + tools=tools, + back='admin:%s_change' % base_url_name, + ) + ), + name=model_tools_url_name), + # changelist + url(r'^tools/(?P\w+)/$', + self.admin_site.admin_view( # checks permissions + ChangeListActionView.as_view( + model=self.model, + tools=tools, + back='admin:%s_changelist' % base_url_name, + ) + ), + # Dupe name is fine. https://code.djangoproject.com/ticket/14259 + name=model_tools_url_name), + ] + + def _get_tool_dict(self, tool_name): + """Represents the tool as a dict with extra meta.""" + tool = getattr(self, tool_name) + standard_attrs, custom_attrs = self._get_tool_button_attrs(tool) + return dict( + name=tool_name, + label=getattr(tool, 'label', tool_name), + standard_attrs=standard_attrs, + custom_attrs=custom_attrs, + ) + def _get_tool_button_attrs(self, tool): """ Get the HTML attributes associated with a tool. @@ -193,7 +196,7 @@ class BaseActionView(View): ---------- back : str The urlpattern name to send users back to. This is set in - `get_tool_urls` and turned into a url with the `back_url` property. + `_get_action_urls` and turned into a url with the `back_url` property. model : django.db.model.Model The model this tool operates on. tools : dict From 47e0eaac8e6e7f4d9aa39950fc3069149bfa03be Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 21:56:49 -0600 Subject: [PATCH 14/16] readme update pass --- README.rst | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/README.rst b/README.rst index 2b48801..7767e08 100644 --- a/README.rst +++ b/README.rst @@ -11,6 +11,7 @@ If you've ever tried making your own admin object tools and you were like me, you immediately gave up. Why can't they be as easy as making Django Admin Actions? Well now they can be. + Quick-Start Guide ----------------- @@ -31,52 +32,53 @@ In your admin.py:: publish_this.label = "Publish" # optional publish_this.short_description = "Submit this article to The Texas Tribune" # optional - objectactions = ('publish_this', ) + change_actions = ('publish_this', ) Usage ----- -Tools are defined just like defining actions as modeladmin methods, see: -`admin -actions `_ -for examples and detailed syntax. You can return nothing or an http -response. The major difference being the functions you write will take +Defining new tool actions are just like defining regular `admin actions +`_. The major +difference is the action functions for you write for the change view will take an object instance instead of a queryset (see *Re-using Admin Actions* below). -Tools are exposed by putting them in an ``objectactions`` attribute in -your modeladmin like:: +Tool actions are exposed by putting them in a ``change_actions`` attribute in +your model admin. You can also add tool actions to the changelist views too. +You'll get a queryset like a regular admin action:: from django_object_actions import DjangoObjectActions - class MyModelAdmin(DjangoObjectActions, admin.ModelAdmin): def toolfunc(self, request, obj): pass toolfunc.label = "This will be the label of the button" # optional toolfunc.short_description = "This will be the tooltip of the button" # optional - objectactions = ('toolfunc', ) + def make_published(modeladmin, request, queryset): + queryset.update(status='p') -Just like actions, you can send a message with ``self.message_user``. + change_actions = ('toolfunc', ) + changelist_actions = ('make_published', ) + +Just like admin actions, you can send a message with ``self.message_user``. Normally, you would do something to the object and go back to the same place, but if you return a HttpResponse, it will follow it (hey, just -like actions!). +like admin actions!). -If your admin modifies ``get_urls``, ``render_change_form``, or -``change_form_template``, you'll need to take extra care. +If your admin modifies ``get_urls``, ``change_view``, or ``changelist_view``, +you'll need to take extra care. Re-using Admin Actions `````````````````````` -If you would like an admin action to also be an object tool, add the -``takes_instance_or_queryset`` decorator like:: +If you would like a preexisting admin action to also be an change action, add +the ``takes_instance_or_queryset`` decorator like:: from django_object_actions import (DjangoObjectActions, takes_instance_or_queryset) - class RobotAdmin(DjangoObjectActions, admin.ModelAdmin): # ... snip ... @@ -84,7 +86,7 @@ If you would like an admin action to also be an object tool, add the def tighten_lug_nuts(self, request, queryset): queryset.update(lugnuts=F('lugnuts') - 1) - objectactions = ['tighten_lug_nuts'] + change_actions = ['tighten_lug_nuts'] actions = ['tighten_lug_nuts'] Customizing Admin Actions @@ -116,8 +118,8 @@ by adding a Django widget style `attrs` attribute:: 'class': 'addlink', } -Programmatically Disabling Object Admin Actions -``````````````````````````````````````````````` +Programmatically Disabling Actions +`````````````````````````````````` You can programmatically disable registered actions by defining your own custom ``get_change_actions()`` method. In this example, certain actions only apply to @@ -197,7 +199,8 @@ Various helpers are available as make commands. Type ``make help`` and view the Similar Packages ---------------- -If you want more UI, check out `Django Admin Row Actions `_. +If you want more UI, check out `Django Admin Row Actions +`_. Django Object Actions is very similar to `django-object-tools `_, From 433d0a29ca96ed4de565db0d36521acd7c5095c1 Mon Sep 17 00:00:00 2001 From: crccheck Date: Sat, 20 Feb 2016 23:37:09 -0600 Subject: [PATCH 15/16] rename more tools things to actions --- django_object_actions/tests/test_admin.py | 8 ++--- django_object_actions/tests/test_utils.py | 22 ++++++------ django_object_actions/tests/tests.py | 14 ++++---- django_object_actions/utils.py | 44 +++++++++++------------ 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/django_object_actions/tests/test_admin.py b/django_object_actions/tests/test_admin.py index f1d187c..ae3fd5e 100644 --- a/django_object_actions/tests/test_admin.py +++ b/django_object_actions/tests/test_admin.py @@ -12,7 +12,7 @@ class CommentTest(LoggedInTestCase): def test_action_on_a_model_with_uuid_pk_works(self): comment = CommentFactory() - url = '/admin/polls/comment/{0}/tools/hodor/'.format(comment.pk) + url = '/admin/polls/comment/{0}/actions/hodor/'.format(comment.pk) # sanity check that url has a uuid self.assertIn('-', url) response = self.client.get(url) @@ -27,12 +27,12 @@ def test_buttons_load(self): self.assertIn('Delete_all', response.rendered_content) def test_changelist_action_view(self): - url = '/admin/polls/choice/tools/delete_all/' + url = '/admin/polls/choice/actions/delete_all/' response = self.client.get(url) self.assertRedirects(response, '/admin/polls/choice/') def test_changelist_nonexistent_action(self): - url = '/admin/polls/choice/tools/xyzzy/' + url = '/admin/polls/choice/actions/xyzzy/' response = self.client.get(url) self.assertEqual(response.status_code, 404) @@ -40,7 +40,7 @@ def test_get_changelist_can_remove_action(self): poll = PollFactory.create() self.assertFalse(poll.question.endswith('?')) admin_change_url = reverse('admin:polls_poll_change', args=(poll.pk,)) - action_url = '/admin/polls/poll/1/tools/question_mark/' + action_url = '/admin/polls/poll/1/actions/question_mark/' # button is in the admin response = self.client.get(admin_change_url) diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index 9d20ec4..b0d6612 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -22,7 +22,7 @@ def test_get_action_urls_trivial_case(self, mock_site): urls = self.instance._get_action_urls() self.assertEqual(len(urls), 2) - self.assertEqual(urls[0].name, 'app_model_tools') + self.assertEqual(urls[0].name, 'app_model_actions') def test_get_change_actions_gets_attribute(self): # Set up @@ -38,42 +38,42 @@ def test_get_change_actions_gets_attribute(self): # Assert self.assertEqual(id(self.instance.change_actions), id(returned_value)) - def test__get_tool_button_attrs_returns_defaults(self): + def test_get_button_attrs_returns_defaults(self): # TODO: use `mock` mock_tool = type('mock_tool', (object, ), {}) - attrs, __ = self.instance._get_tool_button_attrs(mock_tool) + attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs['class'], '') self.assertEqual(attrs['title'], '') - def test__get_tool_button_attrs_disallows_href(self): + def test_get_button_attrs_disallows_href(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'href': 'hreeeeef'}, }) - attrs, __ = self.instance._get_tool_button_attrs(mock_tool) + attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertNotIn('href', attrs) - def test__get_tool_button_attrs_disallows_title(self): + def test_get_button_attrs_disallows_title(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'title': 'i wanna be a title'}, 'short_description': 'real title', }) - attrs, __ = self.instance._get_tool_button_attrs(mock_tool) + attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs['title'], 'real title') - def test__get_tool_button_attrs_gets_set(self): + def test_get_button_attrs_gets_set(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'class': 'class'}, 'short_description': 'description', }) - attrs, __ = self.instance._get_tool_button_attrs(mock_tool) + attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs['class'], 'class') self.assertEqual(attrs['title'], 'description') - def test__get_tool_button_attrs_custom_attrs_get_partitioned(self): + def test_get_button_attrs_custom_attrs_get_partitioned(self): mock_tool = type('mock_tool', (object, ), { 'attrs': {'nonstandard': 'wombat'}, }) - attrs, custom = self.instance._get_tool_button_attrs(mock_tool) + attrs, custom = self.instance._get_button_attrs(mock_tool) self.assertEqual(custom['nonstandard'], 'wombat') diff --git a/django_object_actions/tests/tests.py b/django_object_actions/tests/tests.py index cd4b11b..b3a50cd 100644 --- a/django_object_actions/tests/tests.py +++ b/django_object_actions/tests/tests.py @@ -21,7 +21,7 @@ class AppTests(LoggedInTestCase): def test_tool_func_gets_executed(self): c = Choice.objects.get(pk=1) votes = c.votes - response = self.client.get(reverse('admin:polls_choice_tools', args=(1, 'increment_vote'))) + response = self.client.get(reverse('admin:polls_choice_actions', args=(1, 'increment_vote'))) self.assertEqual(response.status_code, 302) url = reverse('admin:polls_choice_change', args=(1,)) self.assertTrue(response['location'].endswith(url)) @@ -30,7 +30,7 @@ def test_tool_func_gets_executed(self): def test_tool_can_return_httpresponse(self): # we know this url works because of fixtures - url = reverse('admin:polls_choice_tools', args=(2, 'edit_poll')) + url = reverse('admin:polls_choice_actions', args=(2, 'edit_poll')) response = self.client.get(url) # we expect a redirect self.assertEqual(response.status_code, 302) @@ -39,30 +39,30 @@ def test_tool_can_return_httpresponse(self): def test_can_return_template(self): # This is more of a test of render_to_response than the app, but I think # it's good to document that this is something we can do. - url = reverse('admin:polls_poll_tools', args=(1, 'delete_all_choices')) + url = reverse('admin:polls_poll_actions', args=(1, 'delete_all_choices')) response = self.client.get(url) self.assertTemplateUsed(response, "clear_choices.html") def test_message_user_sends_message(self): - url = reverse('admin:polls_poll_tools', args=(1, 'delete_all_choices')) + url = reverse('admin:polls_poll_actions', args=(1, 'delete_all_choices')) self.assertNotIn('messages', self.client.cookies) self.client.get(url) self.assertIn('messages', self.client.cookies) def test_intermediate_page_with_post_works(self): self.assertTrue(Choice.objects.filter(poll=1).count()) - url = reverse('admin:polls_poll_tools', args=(1, 'delete_all_choices')) + url = reverse('admin:polls_poll_actions', args=(1, 'delete_all_choices')) response = self.client.post(url) self.assertEqual(response.status_code, 302) self.assertEqual(Choice.objects.filter(poll=1).count(), 0) def test_undefined_tool_404s(self): - response = self.client.get(reverse('admin:polls_poll_tools', args=(1, 'weeeewoooooo'))) + response = self.client.get(reverse('admin:polls_poll_actions', args=(1, 'weeeewoooooo'))) self.assertEqual(response.status_code, 404) def test_key_error_tool_500s(self): self.assertRaises(KeyError, self.client.get, - reverse('admin:polls_choice_tools', args=(1, 'raise_key_error'))) + reverse('admin:polls_choice_actions', args=(1, 'raise_key_error'))) def test_render_button(self): response = self.client.get(reverse('admin:polls_choice_change', args=(1,))) diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index 743194f..d9c1244 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -21,7 +21,7 @@ class BaseDjangoObjectActions(object): Attributes ---------- model : django.db.models.Model - The Django Model these tools work on. This is populated by Django. + The Django Model these actions work on. This is populated by Django. change_actions : list of str Write the names of the methods of the model admin that can be used as tools in the change view. @@ -98,7 +98,7 @@ def get_changelist_actions(self, request): def _get_action_urls(self): """Get the url patterns that route each action to a view.""" - tools = {} + actions = {} try: model_name = self.model._meta.model_name @@ -107,43 +107,43 @@ def _get_action_urls(self): model_name = self.model._meta.module_name # e.g.: polls_poll base_url_name = '%s_%s' % (self.model._meta.app_label, model_name) - # e.g.: polls_poll_tools - model_tools_url_name = '%s_tools' % base_url_name + # e.g.: polls_poll_actions + model_actions_url_name = '%s_actions' % base_url_name - self.tools_view_name = 'admin:' + model_tools_url_name + self.tools_view_name = 'admin:' + model_actions_url_name # WISHLIST use get_change_actions and get_changelist_actions # TODO separate change and changelist actions - for tool in chain(self.change_actions, self.changelist_actions): - tools[tool] = getattr(self, tool) + for action in chain(self.change_actions, self.changelist_actions): + actions[action] = getattr(self, action) return [ # change, supports pks that are numbers or uuids - url(r'^(?P[0-9a-f\-]+)/tools/(?P\w+)/$', + url(r'^(?P[0-9a-f\-]+)/actions/(?P\w+)/$', self.admin_site.admin_view( # checks permissions ChangeActionView.as_view( model=self.model, - tools=tools, + actions=actions, back='admin:%s_change' % base_url_name, ) ), - name=model_tools_url_name), + name=model_actions_url_name), # changelist - url(r'^tools/(?P\w+)/$', + url(r'^actions/(?P\w+)/$', self.admin_site.admin_view( # checks permissions ChangeListActionView.as_view( model=self.model, - tools=tools, + actions=actions, back='admin:%s_changelist' % base_url_name, ) ), # Dupe name is fine. https://code.djangoproject.com/ticket/14259 - name=model_tools_url_name), + name=model_actions_url_name), ] def _get_tool_dict(self, tool_name): """Represents the tool as a dict with extra meta.""" tool = getattr(self, tool_name) - standard_attrs, custom_attrs = self._get_tool_button_attrs(tool) + standard_attrs, custom_attrs = self._get_button_attrs(tool) return dict( name=tool_name, label=getattr(tool, 'label', tool_name), @@ -151,7 +151,7 @@ def _get_tool_dict(self, tool_name): custom_attrs=custom_attrs, ) - def _get_tool_button_attrs(self, tool): + def _get_button_attrs(self, tool): """ Get the HTML attributes associated with a tool. @@ -190,7 +190,7 @@ class DjangoObjectActions(BaseDjangoObjectActions): class BaseActionView(View): """ - The view that runs a change action tool's callable. + The view that runs a change/changelist action callable. Attributes ---------- @@ -199,12 +199,12 @@ class BaseActionView(View): `_get_action_urls` and turned into a url with the `back_url` property. model : django.db.model.Model The model this tool operates on. - tools : dict - A mapping of tool names to tool callables. + actions : dict + A mapping of action names to callables. """ back = None model = None - tools = None + actions = None @property def view_args(self): @@ -226,11 +226,11 @@ def back_url(self): """ raise NotImplementedError - def get(self, request, **kwargs): + def get(self, request, tool, **kwargs): try: - view = self.tools[kwargs['tool']] + view = self.actions[tool] except KeyError: - raise Http404('Tool does not exist') + raise Http404('Action does not exist') ret = view(request, *self.view_args) if isinstance(ret, HttpResponseBase): From c253a79cbf696b30e80f8fe454fea2e63031d2be Mon Sep 17 00:00:00 2001 From: crccheck Date: Sun, 21 Feb 2016 09:52:53 -0600 Subject: [PATCH 16/16] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fdabb..f6055f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ 0.8.0 (unreleased) ------------------ +* Renames `objectactions` to `change_actions` +* Removes `get_objectactions` (see below) +* Adds `changelist_actions` for creating action tools in the change list view too +* Adds `get_change_actions` and `get_changelist_actions` + ### Breaking changes * Deleted `get_objectactions(request, context, **kwargs)`. If you used this