Browse Source

Mass actions support added to admin's ListView

Rafał Pitoń 11 years ago
parent
commit
e1c5e7e10d

+ 16 - 0
docs/developers/admin_actions.rst

@@ -55,6 +55,9 @@ Base class for lists if items. Supports following properties:
 * **items_per_page** - integer controlling number of items displayed on single page. Defaults to 0 which means no pagination
 * **items_per_page** - integer controlling number of items displayed on single page. Defaults to 0 which means no pagination
 * **SearchForm** - Form type used to construct form for filtering this list. Either this field or ``get_search_form`` method is required to make list searchable.
 * **SearchForm** - Form type used to construct form for filtering this list. Either this field or ``get_search_form`` method is required to make list searchable.
 * **ordering** - list of supported sorting methods. List of tuples. Each tuple should countain two items: name of ordering method (eg. "Usernames, descending") and ``order_by`` argument ("-username"). Defaults to none which means queryset will not be ordered. If contains only one element, queryset is ordered, but option for changing ordering method is not displayed.
 * **ordering** - list of supported sorting methods. List of tuples. Each tuple should countain two items: name of ordering method (eg. "Usernames, descending") and ``order_by`` argument ("-username"). Defaults to none which means queryset will not be ordered. If contains only one element, queryset is ordered, but option for changing ordering method is not displayed.
+* **mass_actions** - tuple of tuples defining list's mass actions. Each tuple should define at least two items: name of ``ListView`` attribute to be called for action, and its button label. Optional third item in tuple will be used for Java Script confirm dialog.
+* **selection_label** - Label displayed on mass action button if there are items selected. ``0`` will be replaced with number of selected items automatically.
+* **empty_selection_label** - Label displayed on mass action button if there are no items selected.
 
 
 In addition to this, ListView defines following methods that you may be interested in overloading:
 In addition to this, ListView defines following methods that you may be interested in overloading:
 
 
@@ -69,6 +72,11 @@ This function is expected to return queryset of items that will be displayed. If
 Class method that allows you to add custom links to item actions. Link should be a string with link name, not complete link. It should also accept same kwargs as other item actions links.
 Class method that allows you to add custom links to item actions. Link should be a string with link name, not complete link. It should also accept same kwargs as other item actions links.
 
 
 
 
+.. function:: add_item_action(cls, action, name, prompt=None)
+
+Class method that allows you to add custom mass action. Action should be name of list method that will be called for this action. Name will be used for button label and optional prompt will be used in JavaScript confirmation dialog that will appear when user clicks button.
+
+
 .. function:: get_search_form(self, request):
 .. function:: get_search_form(self, request):
 
 
 This function is used to get search form class that will be used to construct form for searching list items.
 This function is used to get search form class that will be used to construct form for searching list items.
@@ -80,6 +88,14 @@ If you decide to make your list searchable, remember that your Form must meet fo
 * Must not define fields that use models for values.
 * Must not define fields that use models for values.
 
 
 
 
+If you add custom mass action to view, besides adding new entry to ``mass_actions`` tuple, you have to define custom method following this definition:
+
+
+.. function:: action_NAME(self, request, items)
+
+``NAME`` will be replaced with action name. Request is ``HttpRequest`` instance used to call view and ``items`` is queryset with items selected for this action. This method should nothing or ``HttpResponse``. If you need to, you can raise ``MassActionError`` with error message as its first argument to interrupt mass action handler.
+
+
 FormView
 FormView
 --------
 --------
 
 

+ 1 - 1
misago/admin/views/generic/__init__.py

@@ -1,6 +1,6 @@
 from misago.admin.views.generic.mixin import AdminBaseMixin  # noqa
 from misago.admin.views.generic.mixin import AdminBaseMixin  # noqa
 from misago.admin.views.generic.base import AdminView  # noqa
 from misago.admin.views.generic.base import AdminView  # noqa
-from misago.admin.views.generic.list import ListView  # noqa
+from misago.admin.views.generic.list import ListView, MassActionError  # noqa
 from misago.admin.views.generic.formsbuttons import (TargetedView, FormView,
 from misago.admin.views.generic.formsbuttons import (TargetedView, FormView,
                                                      ModelFormView,
                                                      ModelFormView,
                                                      ButtonView)  # noqa
                                                      ButtonView)  # noqa

+ 77 - 0
misago/admin/views/generic/list.py

@@ -1,11 +1,18 @@
 from urllib import urlencode
 from urllib import urlencode
+from django.contrib import messages
 from django.core.paginator import Paginator, EmptyPage
 from django.core.paginator import Paginator, EmptyPage
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
+from django.db import transaction
 from django.shortcuts import redirect
 from django.shortcuts import redirect
+from django.utils.translation import ugettext_lazy as _
 from misago.core.exceptions import ExplicitFirstPage
 from misago.core.exceptions import ExplicitFirstPage
 from misago.admin.views.generic.base import AdminView
 from misago.admin.views.generic.base import AdminView
 
 
 
 
+class MassActionError(Exception):
+    pass
+
+
 class ListView(AdminView):
 class ListView(AdminView):
     """
     """
     Admin items list view
     Admin items list view
@@ -24,6 +31,17 @@ class ListView(AdminView):
     ordering = None
     ordering = None
 
 
     extra_actions = None
     extra_actions = None
+    mass_actions = None
+
+    selection_label = _('Selected: 0')
+    empty_selection_label = _('Select items')
+
+    @classmethod
+    def add_mass_action(cls, action, name, prompt=None):
+        if not cls.mass_actions:
+            cls.mass_actions = []
+
+        cls.extra_actions.append((action, name, prompt))
 
 
     @classmethod
     @classmethod
     def add_item_action(cls, name, icon, link, style=None):
     def add_item_action(cls, name, icon, link, style=None):
@@ -167,6 +185,44 @@ class ListView(AdminView):
                 context['order_by'].append(order_as_dict)
                 context['order_by'].append(order_as_dict)
 
 
     """
     """
+    Mass actions
+    """
+    def handle_mass_action(self, request, context):
+        limit = self.items_per_page or 64
+        action = self.select_mass_action(request.POST.get('action'))
+        items = [x for x in request.POST.getlist('selected_items')[:limit]]
+
+        context['selected_items'] = items
+        if not context['selected_items']:
+            raise MassActionError(_("You have to select one or more items."))
+
+        action_queryset = context['items'].filter(pk__in=items)
+        if not action_queryset.exists():
+            raise MassActionError(_("You have to select one or more items."))
+
+        action_callable = getattr(self, 'action_%s' % action)
+        with transaction.atomic():
+            return action_callable(request, action_queryset)
+
+    def select_mass_action(self, action):
+        for definition in self.mass_actions:
+            if definition[0] == action:
+                return action
+        else:
+            raise MassActionError(_("Action is not allowed."))
+
+    def mass_actions_as_dicts(self):
+        dicts = []
+        for definition in self.mass_actions or []:
+            dicts.append({
+                'action': definition[0],
+                'name': definition[1],
+                'prompt': definition[2] if len(definition) == 3 else None,
+                })
+        return dicts
+
+
+    """
     Querystrings builder
     Querystrings builder
     """
     """
     def make_querystrings(self, context):
     def make_querystrings(self, context):
@@ -201,25 +257,46 @@ class ListView(AdminView):
     Dispatch response
     Dispatch response
     """
     """
     def dispatch(self, request, *args, **kwargs):
     def dispatch(self, request, *args, **kwargs):
+        mass_actions_list = self.mass_actions_as_dicts()
         extra_actions_list = self.extra_actions or []
         extra_actions_list = self.extra_actions or []
 
 
         refresh_querystring = False
         refresh_querystring = False
 
 
         context = {
         context = {
             'items': self.get_queryset(),
             'items': self.get_queryset(),
+
             'paginator': None,
             'paginator': None,
             'page': None,
             'page': None,
+
             'order_by': [],
             'order_by': [],
             'order': None,
             'order': None,
+
             'search_form': None,
             'search_form': None,
             'active_filters': {},
             'active_filters': {},
+
             'querystring': '',
             'querystring': '',
             'query_order': {},
             'query_order': {},
             'query_filters': {},
             'query_filters': {},
+
+            'selected_items': [],
+            'selection_label': self.selection_label,
+            'empty_selection_label': self.empty_selection_label,
+            'mass_actions': mass_actions_list,
+
             'extra_actions': extra_actions_list,
             'extra_actions': extra_actions_list,
             'extra_actions_len': len(extra_actions_list),
             'extra_actions_len': len(extra_actions_list),
         }
         }
 
 
+        if request.method == 'POST' and mass_actions_list:
+            try:
+                response = self.handle_mass_action(request, context)
+                if response:
+                    return response
+                else:
+                    return redirect(request.path)
+            except MassActionError as e:
+                messages.error(request, e.args[0])
+
         if self.ordering:
         if self.ordering:
             ordering_methods = self.get_ordering_methods(request)
             ordering_methods = self.get_ordering_methods(request)
             used_method = self.get_ordering_method_to_use(ordering_methods)
             used_method = self.get_ordering_method_to_use(ordering_methods)

+ 5 - 0
misago/static/misago/admin/css/misago/tables.less

@@ -17,6 +17,11 @@
 
 
   &>.pull-right {
   &>.pull-right {
     margin-left: @line-height-computed / 2;
     margin-left: @line-height-computed / 2;
+
+    .dropdown-menu {
+      left: auto;
+      right: 0px;
+    }
   }
   }
 }
 }
 
 

+ 4 - 0
misago/static/misago/admin/css/style.css

@@ -6172,6 +6172,10 @@ body {
 .table-actions > .pull-right {
 .table-actions > .pull-right {
   margin-left: 10px;
   margin-left: 10px;
 }
 }
+.table-actions > .pull-right .dropdown-menu {
+  left: auto;
+  right: 0px;
+}
 .table-panel {
 .table-panel {
   border: 1px solid #d4d4d4;
   border: 1px solid #d4d4d4;
   border-radius: 4px;
   border-radius: 4px;

+ 10 - 0
misago/static/misago/admin/js/misago-tables.js

@@ -1,6 +1,16 @@
 // Mass-action tables
 // Mass-action tables
 function tableMassActions(label_none, label_selected) {
 function tableMassActions(label_none, label_selected) {
   var $controller = $('.mass-controller');
   var $controller = $('.mass-controller');
+  var $form = $controller.parents('form');
+
+  $form.find('.dropdown-menu button').click(function() {
+    if ($(this).data('prompt')) {
+      var prompt = confirm($(this).data('prompt'));
+      return prompt;
+    } else {
+      return true;
+    }
+  });
 
 
   function enableController(selected_no) {
   function enableController(selected_no) {
       $controller.removeClass('btn-default');
       $controller.removeClass('btn-default');

+ 2 - 1
misago/templates/misago/admin/bans/list.html

@@ -69,13 +69,14 @@
 
 
 
 
 {% block emptylist %}
 {% block emptylist %}
-<td colspan="{{ 4|add:extra_actions_len }}">
+<td colspan="{{ 6|add:extra_actions_len }}">
   <p>{% trans "No bans are currently set." %}</p>
   <p>{% trans "No bans are currently set." %}</p>
 </td>
 </td>
 {% endblock emptylist %}
 {% endblock emptylist %}
 
 
 
 
 {% block javascripts %}
 {% block javascripts %}
+{{ block.super }}
 <script type="text/javascript">
 <script type="text/javascript">
   $(function() {
   $(function() {
     $('.delete-prompt').submit(function() {
     $('.delete-prompt').submit(function() {

+ 40 - 1
misago/templates/misago/admin/generic/list.html

@@ -8,7 +8,7 @@
 
 
 
 
 {% block view %}
 {% block view %}
-{% if paginator or order_by %}
+{% if paginator or order_by or search_form or mass_actions %}
 <div class="table-actions">
 <div class="table-actions">
 
 
   {% if paginator %}
   {% if paginator %}
@@ -68,6 +68,26 @@
     {% endif %}
     {% endif %}
   {% endif %}
   {% endif %}
 
 
+  {% if mass_actions %}
+  <div class="btn-group pull-right">
+    <form id="mass-action" action="{{ querystring }}" method="post">
+      <button type="button" class="btn btn-default dropdown-toggle mass-controller" data-toggle="dropdown">
+        <span class="fa fa-gears"></span>
+        {% trans "With selected" %}
+      </button>
+      {% csrf_token %}
+      <ul class="dropdown-menu" role="menu">
+        {% for action in mass_actions %}
+        <li>
+          <button type="submit" name="action" value="{{ action.action }}" {% if action.prompt %}data-prompt="{{ action.prompt }}"{% endif %}>
+            {{ action.name }}
+          </button>
+        </li>
+        {% endfor %}
+      </ul>
+    </form>
+  </div>
+  {% endif %}
 
 
 </div><!-- /.table-actions -->
 </div><!-- /.table-actions -->
 {% endif %}
 {% endif %}
@@ -77,12 +97,22 @@
     <tr>
     <tr>
       {% block table-header %}
       {% block table-header %}
       {% endblock table-header %}
       {% endblock table-header %}
+      {% if mass_actions %}
+      <th class="width: 1%;">&nbsp;</th>
+      {% endif %}
     </tr>
     </tr>
 
 
     {% block table-items %}
     {% block table-items %}
     {% for item in items %}
     {% for item in items %}
     <tr>
     <tr>
       {% block table-row %}{% endblock table-row %}
       {% block table-row %}{% endblock table-row %}
+      {% if mass_actions %}
+      <td class="row-select">
+        <label>
+          <input type="checkbox" form="mass-action" name="selected_items" value="{{ item.pk }}" {% if item.pk in selected_items %} checked{% endif %}>
+        </label>
+      </td>
+      {% endif %}
     </tr>
     </tr>
     {% empty %}
     {% empty %}
     <tr class="message-row">
     <tr class="message-row">
@@ -141,3 +171,12 @@
 </div>
 </div>
 {% endif %}
 {% endif %}
 {% endblock content%}
 {% endblock content%}
+
+
+{% block javascripts %}
+<script type="text/javascript">
+  $(function() {
+    tableMassActions("{{ empty_selection_label }}", "{{ selection_label }}");
+  });
+</script>
+{% endblock javascripts %}

+ 1 - 1
misago/users/models.py

@@ -345,7 +345,7 @@ class Ban(models.Model):
     @property
     @property
     def is_expired(self):
     def is_expired(self):
         if self.valid_until:
         if self.valid_until:
-            return self.valid_until <= timezone.now().date
+            return self.valid_until < timezone.now().date
         else:
         else:
             return False
             return False
 
 

+ 15 - 0
misago/users/views/bansadmin.py

@@ -24,7 +24,22 @@ class BanAdmin(generic.AdminBaseMixin):
 
 
 
 
 class BansList(BanAdmin, generic.ListView):
 class BansList(BanAdmin, generic.ListView):
+    items_per_page = 30
     ordering = (('-id', None),)
     ordering = (('-id', None),)
+    selection_label = _('With bans: 0')
+    empty_selection_label = _('Select bans')
+    mass_actions = (
+        (
+            'delete',
+            _('Remove bans'),
+            _('Are you sure you want to remove those bans?')
+        ),
+    )
+
+    def action_delete(self, request, items):
+        items.delete()
+        cachebuster.invalidate('misago_bans')
+        messages.success(request, _("Selected bans have been removed."))
 
 
 
 
 class NewBan(BanAdmin, generic.ModelFormView):
 class NewBan(BanAdmin, generic.ModelFormView):