From e75a49448fcacdaa9d6def9733abbb2890ec47ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 31 Aug 2019 10:38:14 +0200
Subject: [PATCH 2/2] backoffice: add checkboxes to run global actions on many
 items at once (#7865)

---
 tests/test_backoffice_pages.py      | 109 ++++++++++++++++++++++++++++
 wcs/backoffice/management.py        | 100 ++++++++++++++++++++++++-
 wcs/compat.py                       |  10 ++-
 wcs/formdata.py                     |   8 ++
 wcs/forms/backoffice.py             |  44 +++++++++--
 wcs/qommon/static/css/dc2/admin.css |  30 +++++++-
 wcs/qommon/static/js/afterjob.js    |   3 +
 wcs/qommon/static/js/wcs.listing.js |  66 +++++++++++++++++
 wcs/workflows.py                    |  15 ++++
 9 files changed, 372 insertions(+), 13 deletions(-)

diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py
index ff2f471fa..accb8691a 100644
--- a/tests/test_backoffice_pages.py
+++ b/tests/test_backoffice_pages.py
@@ -985,6 +985,115 @@ def test_backoffice_statistics(pub):
     assert '<h2>Filters</h2>' in resp.body
     assert 'End: 2013-01-01' in resp.body
 
+def test_backoffice_multi_actions(pub):
+    create_superuser(pub)
+    create_environment(pub)
+    formdef = FormDef.get_by_urlname('form-title')
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/management/form-title/')
+    assert not 'id="multi-actions"' in resp.body
+
+    workflow = Workflow.get_default_workflow()
+    workflow.id = '2'
+    action = workflow.add_global_action('FOOBAR')
+    jump = action.append_item('jump')
+    jump.status = 'finished'
+    trigger = action.triggers[0]
+    trigger.roles = ['whatever']
+
+    workflow.store()
+    formdef.workflow_id = workflow.id
+    formdef.store()
+
+    resp = app.get('/backoffice/management/form-title/')
+    assert not 'id="multi-actions"' in resp.body
+
+    trigger.roles = [x.id for x in Role.select() if x.name == 'foobar']
+    workflow.store()
+
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'id="multi-actions"' in resp.body
+    ids = []
+    for checkbox in resp.forms[0].fields['select[]'][1:6]:
+        ids.append(checkbox._value)
+        checkbox.checked = True
+    resp = resp.forms[0].submit('button-action-1')
+    assert '?job=' in resp.location
+    resp = resp.follow()
+    assert 'Executing task &quot;FOOBAR&quot; on forms' in resp.body
+    assert '>completed<' in resp.body
+    for id in ids:
+        assert formdef.data_class().get(id).status == 'wf-finished'
+
+    draft_ids = [x.id for x in formdef.data_class().select() if x.status == 'draft']
+    resp = app.get('/backoffice/management/form-title/')
+    assert resp.forms[0].fields['select[]'][0]._value == '_all'
+    resp.forms[0].fields['select[]'][0].checked = True
+    resp = resp.forms[0].submit('button-action-1')
+    for formdata in formdef.data_class().select():
+        if formdata.id in draft_ids:
+            assert formdata.status == 'draft'
+        else:
+            assert formdata.status == 'wf-finished'
+
+    for formdata in formdef.data_class().select():
+        if formdata.status != 'draft':
+            formdata.jump_status('new')
+            formdata.store()
+
+    # action for other role
+    action2 = workflow.add_global_action('OTHER ACTION')
+    jump = action2.append_item('jump')
+    jump.status = 'accepted'
+    trigger = action2.triggers[0]
+    trigger.roles = ['whatever']
+    workflow.store()
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'id="multi-actions"' in resp.body
+    assert not 'OTHER ACTION' in resp.body
+
+    # action for function
+    trigger.roles = ['_foobar']
+    workflow.store()
+
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'id="multi-actions"' in resp.body
+    assert 'OTHER ACTION' not in resp.body
+
+    workflow.roles['_foobar'] = 'Foobar'
+    workflow.store()
+
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'id="multi-actions"' in resp.body
+    assert 'OTHER ACTION' in resp.body
+
+    # alter some formdata to simulate dispatch action
+    stable_ids = []
+    for checkbox in resp.forms[0].fields['select[]'][1:6]:
+        formdata = formdef.data_class().get(checkbox._value)
+        formdata.workflow_roles = {'_foobar': formdef.workflow_roles['_receiver']}
+        formdata.store()
+        stable_ids.append(formdata.id)
+
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'OTHER ACTION' in resp.body
+
+    resp.forms[0].fields['select[]'][0].checked = True  # _all
+    resp = resp.forms[0].submit('button-action-2')
+    assert '?job=' in resp.location
+    resp = resp.follow()
+    assert 'Executing task &quot;OTHER ACTION&quot; on forms' in resp.body
+    # check only dispatched formdata have been moved by global action executed
+    # on all formdatas
+    for formdata in formdef.data_class().select():
+        if formdata.id in draft_ids:
+            assert formdata.status == 'draft'
+        elif formdata.id in stable_ids:
+            assert formdata.status == 'wf-accepted'
+        else:
+            assert formdata.status != 'wf-accepted'
+
 def test_backoffice_statistics_status_filter(pub):
     create_superuser(pub)
     create_environment(pub)
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index 8fe6a18a5..a41f4ff23 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -1330,10 +1330,23 @@ class FormPage(Directory):
     def listing_top_actions(self):
         return ''
 
+    def get_multi_actions(self, user):
+        global_actions = self.formdef.workflow.get_global_manual_actions()
+        workflow_roles = self.formdef.workflow.roles or {}
+        mass_actions = []
+        for action_dict in global_actions:
+            action_dict['roles'] = [x for x in user.get_roles() if x in action_dict.get('roles') or []]
+            if action_dict['functions'] or action_dict['roles']:
+                mass_actions.append(action_dict)
+        return mass_actions
+
     def _q_index(self):
         self.check_access()
         get_logger().info('backoffice - form %s - listing' % self.formdef.name)
 
+        if 'job' in get_request().form:
+            return self.job_multi()
+
         fields = self.get_fields_from_query()
         selected_filter = self.get_filter_from_query()
         criterias = self.get_criterias_from_query()
@@ -1354,10 +1367,31 @@ class FormPage(Directory):
         if get_request().get_query():
             qs = '?' + get_request().get_query()
 
+        multi_actions = self.get_multi_actions(get_request().user)
+        if not get_request().form.get('ajax') == 'true':
+            multi_form = Form(id='multi-actions')
+            for action in multi_actions:
+                attrs = {}
+                if action.get('functions'):
+                    for function in action.get('functions'):
+                        attrs['data-visible_for_%s' % function] = 'true'
+                else:
+                    attrs['data-visible_for_all'] = 'true'
+                multi_form.add_submit('button-action-%s' % action['action'].id, action['action'].name, attrs=attrs)
+            if multi_form.is_submitted() and get_request().form.get('select[]'):
+                for action in multi_actions:
+                    if multi_form.get_submit() == 'button-action-%s' % action['action'].id:
+                        return self.submit_multi(
+                                action,
+                                selected_filter=selected_filter,
+                                query=query,
+                                criterias=criterias)
+
         table = FormDefUI(self.formdef).listing(fields=fields,
                         selected_filter=selected_filter,
                         limit=int(limit), offset=int(offset), query=query,
-                        order_by=order_by, criterias=criterias)
+                        order_by=order_by, criterias=criterias,
+                        include_checkboxes=bool(multi_actions))
         if get_response().status_code == 302:
             # catch early redirect
             return table
@@ -1373,7 +1407,11 @@ class FormPage(Directory):
         r += get_session().display_message()
         r += self.listing_top_actions()
         r += htmltext('</div>')
-        r += table
+        if multi_actions:
+            multi_form.widgets.append(HtmlWidget(table))
+            r += multi_form.render()
+        else:
+            r += table
 
         get_response().filter['sidebar'] = self.get_formdata_sidebar(qs) + \
                 self.get_fields_sidebar(selected_filter, fields, limit=limit,
@@ -1382,6 +1420,64 @@ class FormPage(Directory):
 
         return r.getvalue()
 
+    def submit_multi(self, action, selected_filter, query, criterias):
+        class ActionJob(object):
+            def __init__(self, formdef, query_string, action, item_ids):
+                self.formdef = formdef
+                self.query_string = query_string
+                self.action = action
+                self.item_ids = item_ids
+                self.user = get_request().user
+
+            def execute(self, job=None):
+                formdatas = self.formdef.data_class().get_ids(self.item_ids)
+                publisher = get_publisher()
+                for formdata in formdatas:
+                    publisher.substitutions.reset()
+                    publisher.substitutions.feed(publisher)
+                    publisher.substitutions.feed(self.formdef)
+                    publisher.substitutions.feed(formdata)
+                    formdata.perform_global_action(self.action['action'].id, self.user)
+
+        item_ids = get_request().form['select[]']
+        if '_all' in item_ids:
+            item_ids = FormDefUI(self.formdef).get_listing_item_ids(
+                        selected_filter, user=get_request().user, query=query,
+                        criterias=criterias)
+        action_job = ActionJob(self.formdef, get_request().get_query(), action, item_ids)
+        job = get_response().add_after_job(
+                N_('Executing task "%s" on forms') % action['action'].name,
+                action_job.execute)
+        job.query_string = get_request().get_query()
+        job.store()
+        return redirect('./?job=%s' % job.id)
+
+    def job_multi(self):
+        try:
+            job = AfterJob.get(get_request().form.get('job'))
+        except KeyError:
+            return redirect('.')
+
+        html_top('management', title=_('Executing Task'))
+        r = TemplateIO(html=True)
+        r += get_session().display_message()
+        get_response().add_javascript(['jquery.js', 'afterjob.js'])
+        r += htmltext('<dl class="job-status">')
+        r += htmltext('<dt>')
+        r += _(job.label)
+        r += htmltext('</dt>')
+        r += htmltext('<dd>')
+        r += htmltext('<span class="afterjob" id="%s">') % job.id
+        r += _(job.status)
+        r += htmltext('</span>')
+        r += htmltext('</dd>')
+        r += htmltext('</dl>')
+
+        r += htmltext('<div class="done">')
+        r += htmltext('<a data-redirect-auto="true" href="./?%s">%s</a>') % (job.query_string, _('Back to Listing'))
+        r += htmltext('</div>')
+        return r.getvalue()
+
     def csv_tuple_heading(self, fields):
         heading_fields = [] # '#id', _('time'), _('userlabel'), _('status')]
         for field in fields:
diff --git a/wcs/compat.py b/wcs/compat.py
index 4f6958d55..2c724c791 100644
--- a/wcs/compat.py
+++ b/wcs/compat.py
@@ -116,11 +116,15 @@ class CompatHTTPRequest(HTTPRequest):
             params['charset'] = site_charset
         if not self.form:
             self.form = {}
-        for k, v in self.django_request.POST.items():
+        for k in self.django_request.POST:
             if isinstance(k, unicode):
                 k = k.encode(site_charset)
-            if isinstance(v, unicode):
-                v = v.encode(site_charset)
+            if k.endswith('[]'):
+                v = [x.encode(site_charset) for x in self.django_request.POST.getlist(k)]
+            else:
+                v = self.django_request.POST[k]
+                if isinstance(v, unicode):
+                    v = v.encode(site_charset)
             self.form[k] = v
 
         for k, upload_file in self.django_request.FILES.items():
diff --git a/wcs/formdata.py b/wcs/formdata.py
index dd7f85b0e..65e45f70c 100644
--- a/wcs/formdata.py
+++ b/wcs/formdata.py
@@ -480,6 +480,14 @@ class FormData(StorableObject):
         url = perform_items(wf_status.items, self)
         return url
 
+    def perform_global_action(self, action_id, user):
+        from wcs.workflows import perform_items
+        for action in self.formdef.workflow.get_global_actions_for_user(self, user):
+            if action.id != action_id:
+                continue
+            perform_items(action.items, self)
+            break
+
     def get_workflow_messages(self, position='top'):
         wf_status = self.get_status()
         if not wf_status:
diff --git a/wcs/forms/backoffice.py b/wcs/forms/backoffice.py
index 32922dcf6..0488fd233 100644
--- a/wcs/forms/backoffice.py
+++ b/wcs/forms/backoffice.py
@@ -31,7 +31,8 @@ class FormDefUI(object):
 
     def listing(self, fields, selected_filter='all', url_action=None,
                     items=None, offset=0, limit=0,
-                    query=None, order_by=None, criterias=None):
+                    query=None, order_by=None, criterias=None,
+                    include_checkboxes=False):
 
         partial_display = False
 
@@ -68,6 +69,8 @@ class FormDefUI(object):
         r += htmltext('<table id="listing" class="main compact">')
 
         r += htmltext('<colgroup>')
+        if include_checkboxes:
+            r += htmltext('<col/>')  # checkbox
         r += htmltext('<col/>') # lock
         r += htmltext('<col/>')
         r += htmltext('<col/>')
@@ -78,6 +81,8 @@ class FormDefUI(object):
         using_postgresql = get_publisher().is_using_postgresql()
 
         r += htmltext('<thead><tr>')
+        if include_checkboxes:
+            r += htmltext('<th class="select"><input type="checkbox" name="select[]" value="_all"/></th>')
         if self.formdef.workflow.criticality_levels and using_postgresql:
             r += htmltext('<th style="width: 4ex;" data-field-sort-key="criticality_level"><span></span></th>')
         else:
@@ -105,7 +110,8 @@ class FormDefUI(object):
             r += htmltext('</th>')
         r += htmltext('</tr></thead>')
         r += htmltext('<tbody>')
-        r += htmltext(self.tbody(fields, items, url_action))
+        r += htmltext(self.tbody(fields, items, url_action,
+            include_checkboxes=include_checkboxes))
         r += htmltext('</tbody>')
         r += htmltext('</table>')
 
@@ -115,9 +121,7 @@ class FormDefUI(object):
 
         return r.getvalue()
 
-    def get_listing_items(self, selected_filter='all', offset=None,
-            limit=None, query=None, order_by=None, user=None, criterias=None, anonymise=False):
-        user = user or get_request().user
+    def get_listing_item_ids(self, selected_filter='all', query=None, order_by=None, user=None, criterias=None, anonymise=False):
         formdata_class = self.formdef.data_class()
         if selected_filter == 'all':
             item_ids = formdata_class.keys()
@@ -161,6 +165,20 @@ class FormDefUI(object):
                         'concerned_roles', str(role)))
                 item_ids = list(set(item_ids).intersection(concerned_ids))
 
+        return item_ids
+
+    def get_listing_items(self, selected_filter='all', offset=None,
+            limit=None, query=None, order_by=None, user=None, criterias=None, anonymise=False):
+        user = user or get_request().user
+        formdata_class = self.formdef.data_class()
+
+        item_ids = self.get_listing_item_ids(
+                selected_filter=selected_filter,
+                query=query,
+                user=user,
+                criterias=criterias,
+                anonymise=anonymise)
+
         if order_by and not hasattr(formdata_class, 'get_sorted_ids'):
             # get_sorted_ids is only implemented in the SQL backend
             order_by = None
@@ -186,7 +204,7 @@ class FormDefUI(object):
         return (items, total_count)
 
 
-    def tbody(self, fields=None, items=None, url_action=None):
+    def tbody(self, fields=None, items=None, url_action=None, include_checkboxes=False):
         r = TemplateIO(html=True)
         if url_action:
             pass
@@ -194,7 +212,8 @@ class FormDefUI(object):
         else:
             url_action = ''
         root_url = get_publisher().get_root_url()
-        visited_objects = get_publisher().get_visited_objects(exclude_user=get_session().user)
+        user = get_request().user
+        visited_objects = get_publisher().get_visited_objects(exclude_user=user.id)
         include_criticality_level = bool(self.formdef.workflow.criticality_levels)
         for i, filled in enumerate(items):
             classes = ['status-%s-%s' % (filled.formdef.workflow.id, filled.status)]
@@ -224,6 +243,17 @@ class FormDefUI(object):
             if filled.anonymised:
                 data += ' data-anonymised="true"'
             r += htmltext('<tr class="%s"%s>' % (' '.join(classes), data))
+            if include_checkboxes:
+                r += htmltext('<td class="select"><input type="checkbox" name="select[]" ')
+                r += htmltext('value="%s"') % filled.id
+                workflow_roles = {}
+                workflow_roles.update(self.formdef.workflow_roles)
+                if filled.workflow_roles:
+                    workflow_roles.update(filled.workflow_roles)
+                for function_key, function_value in workflow_roles.items():
+                    if function_value in user.get_roles():
+                        r += htmltext(' data-is-%s="true" ' % function_key)
+                r += htmltext('/></td>')
             if include_criticality_level:
                 r += htmltext('<td %s></td>' % style) # criticality_level
             else:
diff --git a/wcs/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css
index 9314c5b38..9355bf7cb 100644
--- a/wcs/qommon/static/css/dc2/admin.css
+++ b/wcs/qommon/static/css/dc2/admin.css
@@ -395,9 +395,16 @@ table#listing.activity {
 	opacity: 0.5;
 }
 
+#listing tbody tr td {
+	cursor: pointer;
+}
+
+#listing tbody tr td.select {
+	cursor: default;
+}
+
 #listing tbody tr:hover td {
 	background: #dde;
-	cursor: pointer;
 }
 
 #listing.main th,
@@ -413,6 +420,15 @@ table.main th.nosort {
 	cursor: inherit;
 }
 
+table#listing .select {
+	width: 1rem;
+	text-align: center;
+}
+
+table#listing .select input {
+	margin: 2px 0;
+}
+
 table.main th[data-field-sort-key] span:after {
 	padding-left: 1ex;
 	content: "\f0dc"; /* sort */
@@ -1807,3 +1823,15 @@ a.button.button-paragraph p:last-child {
 a.button.button-paragraph:hover p {
 	color: white;
 }
+
+#main-content form#multi-actions {
+	padding: 0;
+}
+
+#main-content form#multi-actions div.buttons {
+	padding: 0 0.5rem;
+}
+
+table#listing tr.checked td {
+	background: #ddf;
+}
diff --git a/wcs/qommon/static/js/afterjob.js b/wcs/qommon/static/js/afterjob.js
index f8fb7bd1d..fde9aa995 100644
--- a/wcs/qommon/static/js/afterjob.js
+++ b/wcs/qommon/static/js/afterjob.js
@@ -8,6 +8,9 @@ function updateStatus()
                 function () {
                     var current_text = $(this).text();
                     if (current_text.indexOf('completed') == 0) {
+                        if ($('div.done a[data-redirect-auto]').length) {
+                            window.location = $('div.done a[data-redirect-auto]').attr('href');
+                        }
                         $(this).text(current_text.substr(10, current_text.length));
                         $(this).addClass('activity-done');
                         $('.afterjob-running').hide();
diff --git a/wcs/qommon/static/js/wcs.listing.js b/wcs/qommon/static/js/wcs.listing.js
index 999c342f9..99b5dc098 100644
--- a/wcs/qommon/static/js/wcs.listing.js
+++ b/wcs/qommon/static/js/wcs.listing.js
@@ -12,6 +12,14 @@ function prepare_row_links() {
     event.stopPropagation();
   });
   $('#listing tbody tr').on('mouseup', function(event) {
+    var $target = $(event.target);
+    if ($target.is('input[type=checkbox]')) {
+      return false;
+    }
+    if ($target.is('td.select')) {
+      $target.find('input').click();
+      return false;
+    }
     var data_link = $(this).data('link');
     if (data_link) {
       if (data_link.indexOf('http://') == -1 && data_link.indexOf('https://') == -1) {
@@ -25,6 +33,60 @@ function prepare_row_links() {
       return false;
     }
   });
+  if ($('#listing tbody input[type=checkbox]:checked').length == 0) {
+    $('form#multi-actions div.buttons').hide();
+  } else {
+    $('form#multi-actions div.buttons').show();
+  }
+  $('#listing tbody input[type=checkbox]').each(function() {
+    if ($(this).is(':checked')) {
+      $(this).parents('tr').addClass('checked');
+    } else {
+      $(this).parents('tr').removeClass('checked');
+    }
+  });
+  $('#listing input[type=checkbox]').on('change', function() {
+    if ($(this).is(':checked')) {
+      if ($(this).is('[value=_all]')) {
+        $(this).parents('table').find('tbody td.select input').prop('checked', true);
+        $(this).parents('table').find('tbody tr').addClass('checked');
+      } else {
+        $(this).parents('tr').addClass('checked');
+      }
+    } else {
+      if ($(this).is('[value=_all]')) {
+        $(this).parents('table').find('tbody td.select input').prop('checked', false);
+        $(this).parents('table').find('tbody tr').removeClass('checked');
+      } else {
+        $(this).parents('tr').removeClass('checked');
+        $('#listing input[type=checkbox][value=_all]').prop('checked', false);
+      }
+    }
+    if ($('#listing tbody input[type=checkbox]:checked').length == 0) {
+      $('form#multi-actions div.buttons').hide();
+      return;
+    } else {
+      $('form#multi-actions div.buttons button').each(function(idx, elem) {
+        var visible = false;
+        for (var key in $(elem).first().data()) {
+          if (key == 'visible_for_all') {
+            visible = true;
+            break;
+          }
+          if ($('input[type=checkbox][data-is-' + key.substr(12) + ']:checked').length) {
+            visible = true;
+            break;
+          }
+        }
+        if (visible) {
+          $(elem).parents('div.widget').show();
+        } else {
+          $(elem).parents('div.widget').hide();
+        }
+      });
+      $('form#multi-actions div.buttons').show();
+    }
+  });
 }
 
 function prepare_column_headers() {
@@ -61,6 +123,10 @@ function prepare_column_headers() {
 }
 
 function autorefresh_table() {
+  if ($('#multi-actions input:checked').length) {
+    // disable autorefresh when multiselection is enabled
+    return;
+  }
   $(document).trigger('backoffice-filter-change',
       {qs: $('form#listing-settings').serialize(), auto: true});
 }
diff --git a/wcs/workflows.py b/wcs/workflows.py
index dd9de272a..aad286866 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -413,6 +413,21 @@ class Workflow(StorableObject):
         self.global_actions.append(action)
         return action
 
+    def get_global_manual_actions(self):
+        actions = []
+        for action in self.global_actions or []:
+            roles = []
+            for trigger in action.triggers or []:
+                if not isinstance(trigger, WorkflowGlobalActionManualTrigger):
+                    continue
+                roles.extend(trigger.roles or [])
+            functions = [x for x in roles if x in self.roles]
+            roles = [x for x in roles if x not in self.roles]
+            if functions or roles:
+                actions.append({'action': action, 'roles': roles, 'functions': functions})
+        return actions
+
+
     def get_global_actions_for_user(self, formdata, user):
         if not user:
             return []
-- 
2.23.0

