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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f6055f7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +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 + 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 85b85ad..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,36 +118,27 @@ by adding a Django widget style `attrs` attribute:: 'class': 'addlink', } -Programmatically Enabling Object Admin Actions -`````````````````````````````````````````````` +Programmatically Disabling 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):: +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_object_actions(self, request, context, **kwargs): - objectactions = [] + 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 [] - # 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'] + obj = self.model.objects.get(pk=object_id) + if obj.question.endswith('?'): + actions.remove('question_mark') - 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', ]) - - return objectactions + return actions +The same is true for changelist actions with ``get_changelist_actions``. Alternate Installation @@ -173,6 +166,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 ----------- @@ -200,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 `_, 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..a79c7c2 --- /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/tests/test_admin.py b/django_object_actions/tests/test_admin.py index b215a3c..ae3fd5e 100644 --- a/django_object_actions/tests/test_admin.py +++ b/django_object_actions/tests/test_admin.py @@ -3,15 +3,52 @@ """ 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): 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) 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/actions/delete_all/' + response = self.client.get(url) + self.assertRedirects(response, '/admin/polls/choice/') + + def test_changelist_nonexistent_action(self): + url = '/admin/polls/choice/actions/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/actions/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/tests/test_utils.py b/django_object_actions/tests/test_utils.py index 1207864..b0d6612 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, ) @@ -17,64 +18,76 @@ 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() - - self.assertEqual(len(urls), 1) - 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 = {} - self.instance.objectactions = mock_objectactions - returned_value = self.instance.get_object_actions( - mock_request, mock_context, **mock_kwargs + 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_actions') + + def test_get_change_actions_gets_attribute(self): + # Set up + self.instance.change_actions = mock.Mock() + + # Test + returned_value = self.instance.get_change_actions( + request=mock.Mock(), + object_id=mock.Mock(), + form_url=mock.Mock(), ) - # assert that `mock_objectactions` was returned - 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): + # Assert + self.assertEqual(id(self.instance.change_actions), id(returned_value)) + + def test_get_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_button_attrs(mock_tool) self.assertEqual(attrs['class'], '') self.assertEqual(attrs['title'], '') - def test_get_djoa_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_djoa_button_attrs(mock_tool) + attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertNotIn('href', attrs) - def test_get_djoa_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_djoa_button_attrs(mock_tool) + attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs['title'], 'real title') - def test_get_djoa_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_djoa_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_djoa_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_djoa_button_attrs(mock_tool) + attrs, custom = self.instance._get_button_attrs(mock_tool) 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/tests/tests.py b/django_object_actions/tests/tests.py index bea4efa..b3a50cd 100644 --- a/django_object_actions/tests/tests.py +++ b/django_object_actions/tests/tests.py @@ -18,21 +18,10 @@ 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 - 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)) @@ -41,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) @@ -50,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 b1dcfe6..d9c1244 100644 --- a/django_object_actions/utils.py +++ b/django_object_actions/utils.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from functools import wraps +from itertools import chain from django.conf.urls import url from django.contrib import messages @@ -10,107 +11,147 @@ 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): """ - ModelAdmin mixin to add object-tools just like adding admin actions. + ModelAdmin mixin to add new actions just like adding admin actions. Attributes ---------- model : django.db.models.Model - The Django Model these tools work on. This is populated by Django. - objectactions : list - Write the names of the callable attributes (methods) of the model admin - that can be used as tools. + 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. + changelist_actions : list of str + 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`. + namespace. Populated by `_get_action_urls`. """ - objectactions = [] + 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 = {} - - # Look for the default change view url and use that as a template - try: - model_name = self.model._meta.model_name - except AttributeError: - # DJANGO15 - 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 = 'admin:%s_change' % base_url_name - - self.tools_view_name = 'admin:' + model_tools_url_name - - for tool in self.objectactions: - tools[tool] = getattr(self, tool) - return [ - # 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( - model=self.model, - tools=tools, - back=change_view, - ) - ), - 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 - - def render_change_form(self, request, context, **kwargs): - """Put `objectactions` into the 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, - ) - - 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) + return self._get_action_urls() + urls + + 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_change_actions(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) + + def changelist_view(self, request, extra_context=None): + extra_context = { + 'objectactions': [ + self._get_tool_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 - ################ + # USER OVERRIDABLE + ################## - def get_object_actions(self, request, context, **kwargs): + def get_change_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`. 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_change_actions(self, request, **kwargs): if request.user.is_superuser: - return super(ChoiceAdmin, self).get_object_actions( - request, context, **kwargs + return super(ChoiceAdmin, self).get_change_actions( + request, **kwargs ) return [] """ - return self.objectactions + return self.change_actions + + def get_changelist_actions(self, request): + """ + Override this to customize what actions get to the changelist view. + """ + return self.changelist_actions + + # INTERNAL METHODS + ################## + + def _get_action_urls(self): + """Get the url patterns that route each action to a view.""" + actions = {} + + 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_actions + model_actions_url_name = '%s_actions' % base_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 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\-]+)/actions/(?P\w+)/$', + self.admin_site.admin_view( # checks permissions + ChangeActionView.as_view( + model=self.model, + actions=actions, + back='admin:%s_change' % base_url_name, + ) + ), + name=model_actions_url_name), + # changelist + url(r'^actions/(?P\w+)/$', + self.admin_site.admin_view( # checks permissions + ChangeListActionView.as_view( + model=self.model, + actions=actions, + back='admin:%s_changelist' % base_url_name, + ) + ), + # Dupe name is fine. https://code.djangoproject.com/ticket/14259 + 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_button_attrs(tool) + return dict( + name=tool_name, + label=getattr(tool, 'label', tool_name), + standard_attrs=standard_attrs, + custom_attrs=custom_attrs, + ) - def get_djoa_button_attrs(self, tool): + def _get_button_attrs(self, tool): """ Get the HTML attributes associated with a tool. @@ -144,42 +185,60 @@ 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): +class BaseActionView(View): """ - The view that runs the tool's callable. + The view that runs a change/changelist action 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_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 - 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() + @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, tool, **kwargs): try: - tool = self.tools[kwargs['tool']] + view = self.actions[tool] except KeyError: - raise Http404(u'Tool does not exist') + raise Http404('Action does not exist') - ret = tool(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 easily + # HACK to allow POST requests too post = get def message_user(self, request, message): @@ -192,6 +251,26 @@ def message_user(self, request, message): messages.info(request, message) +class ChangeActionView(SingleObjectMixin, BaseActionView): + @property + def view_args(self): + return (self.get_object(), ) + + @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): """Decorator that makes standard Django admin actions compatible.""" @wraps(func) @@ -206,7 +285,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/example_project/polls/admin.py b/example_project/polls/admin.py index 22c08fa..1fbc7b4 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 @@ -30,6 +30,9 @@ def decrement_vote(self, request, obj): obj.save() decrement_vote.short_description = "-1" + def delete_all(self, request, queryset): + self.message_user(request, 'just kidding!') + def reset_vote(self, request, obj): obj.votes = 0 obj.save() @@ -42,8 +45,11 @@ 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') + change_actions = ( + 'increment_vote', 'decrement_vote', 'reset_vote', 'edit_poll', + 'raise_key_error', + ) + changelist_actions = ('delete_all',) actions = ['increment_vote'] admin.site.register(Choice, ChoiceAdmin) @@ -57,7 +63,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,11 +81,30 @@ 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" - objectactions = ('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) + 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) @@ -89,5 +115,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) 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 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