From 5638f999d32ea7f6de60b895d23ce89624120769 Mon Sep 17 00:00:00 2001 From: Alexerson Date: Mon, 7 Nov 2022 13:05:06 -0800 Subject: [PATCH] feat: provide action decorator to pass label, description and atts to the admin method (#141) Add an `@action` decorator that behave's like Django's `admin.action` decorator[^1] to clean up customizing object actions. [closes #115](https://github.com/crccheck/django-object-actions/issues/115) Also relates to #107 [^1]: https://docs.djangoproject.com/en/stable/ref/contrib/admin/actions/#django.contrib.admin.action --- README.md | 43 ++++++++++++++++++---- django_object_actions/__init__.py | 1 + django_object_actions/tests/test_utils.py | 41 ++++++++++++++++++++- django_object_actions/utils.py | 45 +++++++++++++++++++++++ example_project/polls/admin.py | 32 ++++++++-------- 5 files changed, 139 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index eba9601..da479d0 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,12 @@ our templates. In your admin.py: ```python -from django_object_actions import DjangoObjectActions +from django_object_actions import DjangoObjectActions, action class ArticleAdmin(DjangoObjectActions, admin.ModelAdmin): + @action(label="Publish", description="Submit this article") # optional def publish_this(self, request, obj): publish_obj(obj) - publish_this.label = "Publish" # optional - publish_this.short_description = "Submit this article" # optional change_actions = ('publish_this', ) ``` @@ -49,10 +48,12 @@ views too. There, you'll get a queryset like a regular [admin action][admin acti from django_object_actions import DjangoObjectActions class MyModelAdmin(DjangoObjectActions, admin.ModelAdmin): + @action( + label="This will be the label of the button", # optional + description="This will be the tooltip of the button" # optional + ) 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 def make_published(modeladmin, request, queryset): queryset.update(status='p') @@ -93,8 +94,18 @@ class RobotAdmin(DjangoObjectActions, admin.ModelAdmin): ### Customizing *Object Actions* -To give the action some a helpful title tooltip, add a -`short_description` attribute, similar to how admin actions work: +To give the action some a helpful title tooltip, you can use the `action` decorator +and set the description argument. + +```python +@action(description="Increment the vote count by one") +def increment_vote(self, request, obj): + obj.votes = obj.votes + 1 + obj.save() +``` + +Alternatively, you can also add a `short_description` attribute, +similar to how admin actions work: ```python def increment_vote(self, request, obj): @@ -107,6 +118,15 @@ By default, Django Object Actions will guess what to label the button based on the name of the function. You can override this with a `label` attribute: +```python +@action(label="Vote++") +def increment_vote(self, request, obj): + obj.votes = obj.votes + 1 + obj.save() +``` + +or + ```python def increment_vote(self, request, obj): obj.votes = obj.votes + 1 @@ -119,6 +139,15 @@ by adding a Django widget style [attrs](https://docs.djangoproject.com/en/stable/ref/forms/widgets/#django.forms.Widget.attrs) attribute: +```python +@action(attrs = {'class': 'addlink'}) +def increment_vote(self, request, obj): + obj.votes = obj.votes + 1 + obj.save() +``` + +or + ```python def increment_vote(self, request, obj): obj.votes = obj.votes + 1 diff --git a/django_object_actions/__init__.py b/django_object_actions/__init__.py index a515a96..e884dff 100644 --- a/django_object_actions/__init__.py +++ b/django_object_actions/__init__.py @@ -7,4 +7,5 @@ BaseDjangoObjectActions, DjangoObjectActions, takes_instance_or_queryset, + action, ) diff --git a/django_object_actions/tests/test_utils.py b/django_object_actions/tests/test_utils.py index fc32428..70cf799 100644 --- a/django_object_actions/tests/test_utils.py +++ b/django_object_actions/tests/test_utils.py @@ -3,7 +3,12 @@ from django.test import TestCase from example_project.polls.models import Poll -from ..utils import BaseDjangoObjectActions, BaseActionView, takes_instance_or_queryset +from ..utils import ( + BaseDjangoObjectActions, + BaseActionView, + takes_instance_or_queryset, + action, +) class BaseDjangoObjectActionsTest(TestCase): @@ -122,3 +127,37 @@ def myfunc(foo, bar, queryset): queryset = myfunc(None, None, queryset=self.obj) # the resulting queryset only has one item and it's self.obj self.assertEqual(queryset.get(), self.obj) + + +class DecoratorActionTest(TestCase): + def test_decorated(self): + # setup + @action(description="First action of this admin site.") + def action_1(modeladmin, request, queryset): + pass + + @action(permissions=["do_action2"]) + def action_2(modeladmin, request, queryset): + pass + + @action(label="Third action") + def action_3(modeladmin, request, queryset): + pass + + @action( + attrs={ + "class": "addlink", + } + ) + def action_4(modeladmin, request, queryset): + pass + + self.assertEqual(action_1.short_description, "First action of this admin site.") + self.assertEqual(action_2.allowed_permissions, ["do_action2"]) + self.assertEqual(action_3.label, "Third action") + self.assertEqual( + action_4.attrs, + { + "class": "addlink", + }, + ) diff --git a/django_object_actions/utils.py b/django_object_actions/utils.py index f0b3864..095ab11 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -311,3 +311,48 @@ def decorated_function(self, request, queryset): return func(self, request, queryset) return decorated_function + + +def action( + function=None, *, permissions=None, description=None, label=None, attrs=None +): + """ + Conveniently add attributes to an action function:: + + @action( + permissions=['publish'], + description='Mark selected stories as published', + label='Publish' + ) + def make_published(self, request, queryset): + queryset.update(status='p') + + This is equivalent to setting some attributes (with the original, longer + names) on the function directly:: + + def make_published(self, request, queryset): + queryset.update(status='p') + make_published.allowed_permissions = ['publish'] + make_published.short_description = 'Mark selected stories as published' + make_published.label = 'Publish' + + This is the django-object-actions equivalent of + https://docs.djangoproject.com + /en/dev/ref/contrib/admin/actions/#django.contrib.admin.action + """ + + def decorator(func): + if permissions is not None: + func.allowed_permissions = permissions + if description is not None: + func.short_description = description + if label is not None: + func.label = label + if attrs is not None: + func.attrs = attrs + return func + + if function is None: + return decorator + else: + return decorator(function) diff --git a/example_project/polls/admin.py b/example_project/polls/admin.py index 47d115b..72c2e74 100644 --- a/example_project/polls/admin.py +++ b/example_project/polls/admin.py @@ -4,7 +4,11 @@ from django.http import HttpResponseRedirect from django.urls import reverse -from django_object_actions import DjangoObjectActions, takes_instance_or_queryset +from django_object_actions import ( + DjangoObjectActions, + takes_instance_or_queryset, + action, +) from .models import Choice, Poll, Comment, RelatedData @@ -15,38 +19,37 @@ class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin): # Actions ######### + @action( + description="+1", + label="vote++", + attrs={ + "test": '"foo&bar"', + "Robert": '"); DROP TABLE Students; ', # 327 + "class": "addlink", + }, + ) @takes_instance_or_queryset def increment_vote(self, request, queryset): queryset.update(votes=F("votes") + 1) - increment_vote.short_description = "+1" - increment_vote.label = "vote++" - increment_vote.attrs = { - "test": '"foo&bar"', - "Robert": '"); DROP TABLE Students; ', # 327 - "class": "addlink", - } - actions = ["increment_vote"] # Object actions ################ + @action(description="-1") def decrement_vote(self, request, obj): obj.votes -= 1 obj.save() - decrement_vote.short_description = "-1" - def delete_all(self, request, queryset): self.message_user(request, "just kidding!") + @action(description="0") def reset_vote(self, request, obj): obj.votes = 0 obj.save() - reset_vote.short_description = "0" - def edit_poll(self, request, obj): url = reverse("admin:polls_poll_change", args=(obj.poll.pk,)) return HttpResponseRedirect(url) @@ -101,6 +104,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None): # Object actions ################ + @action(label="Delete All Choices") def delete_all_choices(self, request, obj): from django.shortcuts import render @@ -111,8 +115,6 @@ def delete_all_choices(self, request, obj): self.message_user(request, "All choices deleted") return render(request, "clear_choices.html", {"object": obj}) - delete_all_choices.label = "Delete All Choices" - def question_mark(self, request, obj): """Add a question mark.""" obj.question = obj.question + "?"