From 256efa679ef08de0dd0a6ec871409b936adc0bd5 Mon Sep 17 00:00:00 2001 From: d1ffuz0r Date: Fri, 14 Jun 2013 20:58:27 +0700 Subject: [PATCH 1/2] #154 changed fbc action views behavior to cbv. added breadcrumbs to actions with multiple items --- djadmin2/actions.py | 138 +++++++++--------- .../actions/delete_selected_confirmation.html | 10 +- djadmin2/views.py | 6 +- docs/ref/actions.rst | 22 ++- example/blog/actions.py | 19 +++ example/blog/admin2.py | 12 ++ example/blog/models.py | 1 + .../actions/publish_selected_items.html | 34 +++++ example/blog/tests/test_views.py | 36 +++++ 9 files changed, 203 insertions(+), 75 deletions(-) create mode 100644 example/blog/actions.py create mode 100644 example/blog/templates/admin2/bootstrap/actions/publish_selected_items.html diff --git a/djadmin2/actions.py b/djadmin2/actions.py index c8cd900..71ae643 100644 --- a/djadmin2/actions.py +++ b/djadmin2/actions.py @@ -1,12 +1,12 @@ from django.contrib import messages -from django.core.exceptions import PermissionDenied -from django.template.response import TemplateResponse +from django.views.generic import TemplateView from django.utils.encoding import force_text from django.utils.text import capfirst from django.utils.translation import ugettext_lazy from django.utils.translation import ugettext as _ -from . import utils +from . import permissions, utils +from .viewmixins import AdminModel2Mixin def get_description(action): @@ -16,45 +16,55 @@ def get_description(action): return capfirst(action.__name__.replace('_', ' ')) -class BaseListAction(object): +class BaseListAction(AdminModel2Mixin, TemplateView): - def __init__(self, request, queryset): - self.request = request + permission_classes = (permissions.IsStaffPermission,) + + empty_message = 'Items must be selected in order to perform actions on them. No items have been changed.' + success_message = 'Successfully deleted %d %s' + + queryset = None + + def __init__(self, queryset, *args, **kwargs): self.queryset = queryset self.model = queryset.model - self.options = utils.model_options(self.model) + + options = utils.model_options(self.model) + + self.app_label = options.app_label + self.model_name = options.module_name self.item_count = len(queryset) if self.item_count <= 1: - objects_name = self.options.verbose_name + objects_name = options.verbose_name else: - objects_name = self.options.verbose_name_plural + objects_name = options.verbose_name_plural self.objects_name = unicode(objects_name) - @property - def permission_name(self): - return None + super(BaseListAction, self).__init__(*args, **kwargs) + + def get_queryset(self): + """ Replaced `get_queryset` from `AdminModel2Mixin`""" + return self.queryset def description(self): - raise NotImplementedError("List action classes require a description attribute.") - - def render_or_none(self): - """ Returns either: - Http response (anything) - None object (shows the list) - """ - raise NotImplementedError("List action classes require a render_or_none method that returns either a None or HTTP response object.") + raise NotImplementedError("List action classes require" + " a description attribute.") @property - def template_for_display_nested_response(self): - """ This is a required attribute for when using the `display_nested_response` method. """ - raise NotImplementedError("List actions classes using display_nested_response require a template") + def default_template_name(self): + raise NotImplementedError( + "List actions classes using display_nested_response" + " require a template" + ) - def display_nested_response(self): + def get_context_data(self, **kwargs): """ Utility method when you want to display nested objects - (such as during a bulk update/delete + (such as during a bulk update/delete) """ + context = super(BaseListAction, self).get_context_data() + def _format_callback(obj): opts = utils.model_options(obj) return '%s: %s' % (force_text(capfirst(opts.verbose_name)), @@ -63,27 +73,41 @@ class BaseListAction(object): collector = utils.NestedObjects(using=None) collector.collect(self.queryset) - context = { - 'queryset': self.queryset, + context.update({ + 'view': self, 'objects_name': self.objects_name, + 'queryset': self.queryset, 'deletable_objects': collector.nested(_format_callback), - } - return TemplateResponse(self.request, self.template_for_display_nested_response, context) + }) - def __call__(self): - # We check whether the user has permission to delete the objects in the - # queryset. - if self.permission_name and not self.request.user.has_perm(self.permission_name): - message = _("Permission to '%s' denied" % force_text(self.description)) - messages.add_message(self.request, messages.INFO, message) - return None + return context + def get(self, request): if self.item_count > 0: - return self.render_or_none() + return super(BaseListAction, self).get(request) + + message = _(self.empty_message) + messages.add_message(request, messages.INFO, message) + + return None + + def post(self, request): + if request.POST.get('confirmed'): + if self.process_queryset() is None: + + message = _(self.success_message % ( + self.item_count, self.objects_name) + ) + messages.add_message(request, messages.INFO, message) + + return None else: - message = _("Items must be selected in order to perform actions on them. No items have been changed.") - messages.add_message(self.request, messages.INFO, message) - return None + # The user has not confirmed that they want to delete the objects, so + # render a template asking for their confirmation. + return self.get(request) + + def process_queryset(self): + raise NotImplementedError('Must be provided to do some actions with queryset') class DeleteSelectedAction(BaseListAction): @@ -91,31 +115,13 @@ class DeleteSelectedAction(BaseListAction): # `get_deleted_objects` in contrib.admin.util for how this is currently # done. (Hint: I think we can do better.) + default_template_name = "actions/delete_selected_confirmation.html" + description = ugettext_lazy("Delete selected items") + permission_classes = BaseListAction.permission_classes + ( + permissions.ModelDeletePermission, + ) - @property - def permission_name(self): - return '%s.delete.%s' \ - % (self.options.app_label, self.options.object_name.lower()) - - def render_or_none(self): - - if self.request.POST.get('confirmed'): - # The user has confirmed that they want to delete the objects. - num_objects_deleted = len(self.queryset) - self.queryset.delete() - message = _("Successfully deleted %d %s" % \ - (num_objects_deleted, self.objects_name)) - messages.add_message(self.request, messages.INFO, message) - return None - else: - # The user has not confirmed that they want to delete the objects, so - # render a template asking for their confirmation. - return self.display_nested_response() - - @property - def template_for_display_nested_response(self): - # TODO - power this off the ADMIN2_THEME_DIRECTORY setting - return "admin2/bootstrap/actions/delete_selected_confirmation.html" - - + def process_queryset(self): + # The user has confirmed that they want to delete the objects. + self.get_queryset().delete() diff --git a/djadmin2/templates/admin2/bootstrap/actions/delete_selected_confirmation.html b/djadmin2/templates/admin2/bootstrap/actions/delete_selected_confirmation.html index 9b61ac9..53fdd5a 100644 --- a/djadmin2/templates/admin2/bootstrap/actions/delete_selected_confirmation.html +++ b/djadmin2/templates/admin2/bootstrap/actions/delete_selected_confirmation.html @@ -1,10 +1,18 @@ {% extends "admin2/bootstrap/base.html" %} -{% load i18n %} +{% load admin2_tags i18n %} {% block title %}Are you sure?{% endblock title %} {% block page_title %}Are you sure?{% endblock page_title %} +{% block breadcrumbs %} +
  • Home /
  • +
  • {{ app_label|title }} /
  • +
  • {{ model_name_pluralized|title }} /
  • +
  • {% trans "Delete" %}
  • +{% endblock breadcrumbs %} + + {% block content %}

    {% blocktrans with objects_name=objects_name %}Are you sure you want to delete the selected {{ objects_name }}? All of the following items will be deleted:{% endblocktrans %}

    diff --git a/djadmin2/views.py b/djadmin2/views.py index e20dfa7..1c75698 100644 --- a/djadmin2/views.py +++ b/djadmin2/views.py @@ -63,11 +63,12 @@ class ModelListView(AdminModel2Mixin, generic.ListView): # If action_callable is a class subclassing from actions.BaseListAction # then we generate the callable object. - if hasattr(action_callable, "render_or_none"): - response = action_callable(request, queryset)() + if hasattr(action_callable, "process_queryset"): + response = action_callable.as_view(queryset=queryset)(request) else: # generate the reponse if a function. response = action_callable(request, queryset) + if response is None: return HttpResponseRedirect(self.get_success_url()) else: @@ -85,7 +86,6 @@ class ModelListView(AdminModel2Mixin, generic.ListView): else: return "%s__icontains" % field_name - use_distinct = False orm_lookups = [construct_search(str(search_field)) diff --git a/docs/ref/actions.rst b/docs/ref/actions.rst index 74ccc99..216b6db 100644 --- a/docs/ref/actions.rst +++ b/docs/ref/actions.rst @@ -43,7 +43,7 @@ The basic workflow of Django’s admin is, in a nutshell, “select an object, t In these cases, Django’s admin lets you write and register “actions” – simple functions that get called with a list of objects selected on the change list page. -If you look at any change list in the admin, you’ll see this feature in action; Django ships with a “delete selected objects” action available to all models. Using our sample models, let's pretend we wrote a blog article about Django and our mother put in a whole bunch of embarressing comments. Rather than cherry-pick the comments, we want to delete the whole batch. +If you look at any change list in the admin, you’ll see this feature in action; Django ships with a “delete selected objects” action available to all models. Using our sample models, let's pretend we wrote a blog article about Django and our mother put in a whole bunch of embarressing comments. Rather than cherry-pick the comments, we want to delete the whole batch. In our blog/admin.py module we write: @@ -54,11 +54,23 @@ In our blog/admin.py module we write: from .models import Post, Comment class DeleteAllComments(djadmin2.actions.BaseListAction): - description = "Delete selected items" - template = "blog/actions/delete_all_comments_confirmation.html + + description = 'Delete selected items' + default_template_name = 'actions/delete_all_comments_confirmation.html' + success_message = 'Successfully deleted %d %s' # first argument - items count, second - verbose_name[_plural] + + def process_query(self): + """Every action must provide this method""" + self.get_queryset().delete() + + + def custom_function_action(request, queryset): + print(queryset.count()) + + custom_function_action.description = 'Do other action' class PostAdmin(djadmin2.ModelAdmin2): - actions = [DeleteAllComments] + actions = [DeleteAllComments, custom_function_action] djadmin2.default.register(Post, PostAdmin) djadmin2.default.register(Comment) @@ -75,4 +87,4 @@ In our blog/admin.py module we write: .. _`QuerySet.delete()`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#django.db.models.query.QuerySet.delete .. _`Object deletion`: https://docs.djangoproject.com/en/dev/topics/db/queries/#topics-db-queries-delete -Read on to find out how to add your own actions to this list. \ No newline at end of file +Read on to find out how to add your own actions to this list. diff --git a/example/blog/actions.py b/example/blog/actions.py new file mode 100644 index 0000000..8603b3c --- /dev/null +++ b/example/blog/actions.py @@ -0,0 +1,19 @@ +from djadmin2.actions import BaseListAction +from djadmin2 import permissions + +from django.utils.translation import ugettext_lazy + + +class CustomPublishAction(BaseListAction): + + permission_classes = BaseListAction.permission_classes + ( + permissions.ModelChangePermission, + ) + + description = ugettext_lazy('Publish selected items') + success_message = 'Successfully published %d %s' + + default_template_name = "actions/publish_selected_items.html" + + def process_queryset(self): + self.get_queryset().update(published=True) diff --git a/example/blog/admin2.py b/example/blog/admin2.py index bea6ad9..f23bb42 100644 --- a/example/blog/admin2.py +++ b/example/blog/admin2.py @@ -1,12 +1,15 @@ # Import your custom models from django.contrib.auth.models import Group, User +from django.contrib import messages from rest_framework.relations import PrimaryKeyRelatedField import djadmin2 +from djadmin2.actions import DeleteSelectedAction from djadmin2.forms import UserCreationForm, UserChangeForm from djadmin2.apiviews import Admin2APISerializer +from .actions import CustomPublishAction from .models import Post, Comment @@ -33,10 +36,19 @@ class CommentInline(djadmin2.Admin2Inline): model = Comment +def unpublish_items(request, queryset): + queryset.update(published=False) + messages.add_message(request, messages.INFO, u'Items unpublished') + +unpublish_items.description = 'Unpublish selected items' + + class PostAdmin(djadmin2.ModelAdmin2): + list_actions = [DeleteSelectedAction, CustomPublishAction, unpublish_items] inlines = [CommentInline] search_fields = ('title', '^body') + class CommentAdmin(djadmin2.ModelAdmin2): search_fields = ('body', '=post__title') diff --git a/example/blog/models.py b/example/blog/models.py index 4b73e9f..cc4a28b 100644 --- a/example/blog/models.py +++ b/example/blog/models.py @@ -6,6 +6,7 @@ from django.utils.encoding import python_2_unicode_compatible class Post(models.Model): title = models.CharField(max_length=255) body = models.TextField() + published = models.BooleanField(default=False) def __unicode__(self): return self.title diff --git a/example/blog/templates/admin2/bootstrap/actions/publish_selected_items.html b/example/blog/templates/admin2/bootstrap/actions/publish_selected_items.html new file mode 100644 index 0000000..988e435 --- /dev/null +++ b/example/blog/templates/admin2/bootstrap/actions/publish_selected_items.html @@ -0,0 +1,34 @@ +{% extends "admin2/bootstrap/base.html" %} +{% load admin2_tags i18n %} + +{% block title %}Are you sure?{% endblock title %} + +{% block page_title %}Are you sure?{% endblock page_title %} + +{% block breadcrumbs %} +
  • Home /
  • +
  • {{ app_label|title }} /
  • +
  • {{ model_name_pluralized|title }} /
  • +
  • {% trans "Publish" %}
  • +{% endblock breadcrumbs %} + + +{% block content %} + +

    {% blocktrans with objects_name=objects_name %}Are you sure you want to publish the selected {{ objects_name }}? All of the following items will be published:{% endblocktrans %}

    + + + +
    + {% csrf_token %} + + + {% for item in queryset %} + + {% endfor %} + +
    + +{% endblock content %} diff --git a/example/blog/tests/test_views.py b/example/blog/tests/test_views.py index 48e10b2..93fd1dd 100644 --- a/example/blog/tests/test_views.py +++ b/example/blog/tests/test_views.py @@ -89,6 +89,42 @@ class PostListTest(BaseIntegrationTest): self.assertNotContains(response, "a_post_title") +class PostListTestCustomAction(BaseIntegrationTest): + + def test_publish_action_displayed_in_list(self): + response = self.client.get(reverse("admin2:blog_post_index")) + self.assertInHTML('Publish selected items', response.content) + + def test_publish_selected_items(self): + post = Post.objects.create(title="a_post_title", + body="body", + published=False) + self.assertEqual(Post.objects.filter(published=True).count(), 0) + + params = {'action': 'CustomPublishAction', + 'selected_model_pk': str(post.pk), + 'confirmed': 'yes'} + response = self.client.post(reverse("admin2:blog_post_index"), params) + self.assertRedirects(response, reverse("admin2:blog_post_index")) + + self.assertEqual(Post.objects.filter(published=True).count(), 1) + + def test_unpublish_action_displayed_in_list(self): + response = self.client.get(reverse("admin2:blog_post_index")) + self.assertInHTML('Unpublish selected items', response.content) + + def test_unpublish_selected_items(self): + post = Post.objects.create(title="a_post_title", + body="body", + published=True) + self.assertEqual(Post.objects.filter(published=True).count(), 1) + + params = {'action': 'unpublish_items', + 'selected_model_pk': str(post.pk)} + response = self.client.post(reverse("admin2:blog_post_index"), params) + self.assertRedirects(response, reverse("admin2:blog_post_index")) + + self.assertEqual(Post.objects.filter(published=True).count(), 0) class PostDetailViewTest(BaseIntegrationTest): From 381361b648e83aacb75843f222ac07029797441a Mon Sep 17 00:00:00 2001 From: Daniel Greenfeld Date: Tue, 18 Jun 2013 18:19:37 +0300 Subject: [PATCH 2/2] Add Ethan Soergel to authors --- AUTHORS.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 44195f7..db1aaea 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -18,10 +18,11 @@ Developers * Chris Lawlor (@chrislawlor) * Ben Tappin * Allison Kapture (@akapture) -* Roman Gladkov (@d1ffuz0r / d1fffuz0r@gmail.com) +* Roman Gladkov (@d1ffuz0r / ) * Pau Rosello Van Schoor (@paurosello) * Wade Austin (@waustin) * the5fire (@the5fire) -* Andrews Medina (@andrewsmedina / andrewsmedina@gmail.com) +* Andrews Medina (@andrewsmedina / ) * Wade Austin (@waustin) * Douglas Miranda +* Ethan Soergel (@esoergel / )