From 77770f2618693ed21dcf4b75c92447bd31d5b76d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 4 May 2020 16:59:24 +0200
Subject: [PATCH] backoffice: add multi-action support for status manual jump
 action (#37983)

---
 tests/test_backoffice_pages.py      | 123 +++++++++++++++++++---------
 wcs/backoffice/management.py        |  70 ++++++++++------
 wcs/qommon/static/js/wcs.listing.js |  17 +++-
 wcs/workflows.py                    |  27 +++++-
 4 files changed, 170 insertions(+), 67 deletions(-)

diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py
index 2f2989b6a..4ad30ca77 100644
--- a/tests/test_backoffice_pages.py
+++ b/tests/test_backoffice_pages.py
@@ -317,8 +317,8 @@ def test_backoffice_listing(pub):
 
     # check status filter <select>
     resp = app.get('/backoffice/management/form-title/')
-    resp.forms[0]['filter'] = 'all'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'all'
+    resp = resp.forms['listing-settings'].submit()
     if pub.is_using_postgresql():
         assert resp.text.count('data-link') == 20
     else:
@@ -327,8 +327,8 @@ def test_backoffice_listing(pub):
 
     # check status filter <select>
     resp = app.get('/backoffice/management/form-title/')
-    resp.forms[0]['filter'] = 'done'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'done'
+    resp = resp.forms['listing-settings'].submit()
     if pub.is_using_postgresql():
         assert resp.text.count('data-link') == 20
         resp = resp.click('Next Page')
@@ -361,8 +361,8 @@ def test_backoffice_listing(pub):
 
     resp = app.get('/backoffice/management/form-title/')
     assert resp.text.count('data-link') == 9
-    resp.forms[0]['filter'] = 'pending'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'pending'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('data-link') == 17
 
     # check status forced as endpoints are not part of the "actionable" list.
@@ -392,8 +392,8 @@ def test_backoffice_listing(pub):
 
     resp = app.get('/backoffice/management/form-title/')
     assert resp.text.count('data-link') == 17
-    resp.forms[0]['filter'] = 'pending'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'pending'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('data-link') == 17
 
     # mark status as an endpoint
@@ -403,8 +403,8 @@ def test_backoffice_listing(pub):
 
     resp = app.get('/backoffice/management/form-title/')
     assert resp.text.count('data-link') == 9
-    resp.forms[0]['filter'] = 'pending'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'pending'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('data-link') == 9
 
 
@@ -542,10 +542,10 @@ def test_backoffice_columns(pub):
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
     assert resp.text.count('</th>') == 8 # six columns
-    resp.forms[0]['1'].checked = False
-    assert not 'submission_channel' in resp.forms[0].fields
-    assert 'last_update_time' in resp.forms[0].fields
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['1'].checked = False
+    assert not 'submission_channel' in resp.forms['listing-settings'].fields
+    assert 'last_update_time' in resp.forms['listing-settings'].fields
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('</th>') == 7 # fixe columns
     assert resp.text.count('data-link') == 17 # 17 rows
     assert resp.text.count('FOO BAR') == 0 # no field 1 column
@@ -570,8 +570,8 @@ def test_backoffice_channel_column(pub):
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
     assert resp.text.count('</th>') == 8 # six columns
-    resp.forms[0]['submission_channel'].checked = True
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['submission_channel'].checked = True
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('</th>') == 9 # seven columns
     assert resp.text.count('data-link') == 17 # 17 rows
     assert resp.text.count('<td>Web</td>') == 17
@@ -640,42 +640,42 @@ def test_backoffice_filter(pub):
     create_environment(pub)
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    assert resp.forms[0]['filter-status'].checked == True
-    resp.forms[0]['filter-status'].checked = False
-    resp.forms[0]['filter-2'].checked = True
-    resp = resp.forms[0].submit()
+    assert resp.forms['listing-settings']['filter-status'].checked == True
+    resp.forms['listing-settings']['filter-status'].checked = False
+    resp.forms['listing-settings']['filter-2'].checked = True
+    resp = resp.forms['listing-settings'].submit()
     assert '<select name="filter">' not in resp.text
 
-    resp.forms[0]['filter-2-value'] = 'baz'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-2-value'] = 'baz'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>baz</td>') == 8
     assert resp.text.count('<td>foo</td>') == 0
     assert resp.text.count('<td>bar</td>') == 0
 
-    resp.forms[0]['filter-start'].checked = True
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-start-value'] = datetime.datetime(2015, 2, 1).strftime('%Y-%m-%d')
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-start'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-start-value'] = datetime.datetime(2015, 2, 1).strftime('%Y-%m-%d')
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>baz</td>') == 0
-    resp.forms[0]['filter-start-value'] = datetime.datetime(2014, 2, 1).strftime('%Y-%m-%d')
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-start-value'] = datetime.datetime(2014, 2, 1).strftime('%Y-%m-%d')
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>baz</td>') == 8
 
     # check there's no crash on invalid date values
-    resp.forms[0]['filter-start-value'] = 'whatever'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-start-value'] = 'whatever'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>baz</td>') == 8
 
     # check two-digit years are handled correctly
-    resp.forms[0]['filter-start-value'] = datetime.datetime(2014, 2, 1).strftime('%y-%m-%d')
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-start-value'] = datetime.datetime(2014, 2, 1).strftime('%y-%m-%d')
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>baz</td>') == 8
 
     # check it's also ok for end filter
-    resp.forms[0]['filter-end'].checked = True
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-end-value'] = datetime.datetime(2014, 2, 2).strftime('%y-%m-%d')
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-end'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-end-value'] = datetime.datetime(2014, 2, 2).strftime('%y-%m-%d')
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>baz</td>') == 0
 
 
@@ -1447,7 +1447,7 @@ def test_backoffice_multi_actions(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    assert not 'id="multi-actions"' in resp.text
+    assert 'id="multi-actions"' in resp.text  # always there
 
     workflow = Workflow.get_default_workflow()
     workflow.id = '2'
@@ -1462,7 +1462,7 @@ def test_backoffice_multi_actions(pub):
     formdef.store()
 
     resp = app.get('/backoffice/management/form-title/')
-    assert not 'id="multi-actions"' in resp.text
+    assert 'id="multi-actions"' in resp.text
 
     trigger.roles = [x.id for x in Role.select() if x.name == 'foobar']
     workflow.store()
@@ -1550,6 +1550,51 @@ def test_backoffice_multi_actions(pub):
             assert formdata.status != 'wf-accepted'
 
 
+def test_backoffice_multi_actions_jump(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/')
+
+    workflow = Workflow.get_default_workflow()
+    workflow.id = '2'
+    workflow.store()
+    formdef.workflow_id = workflow.id
+    formdef.store()
+
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'select[]' not in resp.forms['multi-actions'].fields
+
+    resp.forms['listing-settings']['filter'] = 'new'
+    resp = resp.forms['listing-settings'].submit()
+    assert 'select[]' not in resp.forms['multi-actions'].fields
+
+    # add identifier to jumps
+    workflow.get_status('new').items[1].identifier = 'accept'
+    workflow.get_status('new').items[2].identifier = 'reject'
+    workflow.get_status('new').items[2].require_confirmation = True
+    workflow.store()
+
+    resp = resp.forms['listing-settings'].submit()
+    assert 'select[]' in resp.forms['multi-actions'].fields
+    assert len(resp.pyquery.find('#multi-actions div.buttons button')) == 2
+    assert len(resp.pyquery.find('#multi-actions div.buttons button[data-ask-for-confirmation]')) == 1
+
+    ids = []
+    for checkbox in resp.forms[0].fields['select[]'][1:6]:
+        ids.append(checkbox._value)
+        checkbox.checked = True
+    resp = resp.forms['multi-actions'].submit('button-action-st-accept')
+    assert '?job=' in resp.location
+    resp = resp.follow()
+    assert 'Executing task &quot;Accept&quot; on forms' in resp.text
+    assert '>completed<' in resp.text
+    for id in ids:
+        assert formdef.data_class().get(id).status == 'wf-accepted'
+
+
 def test_backoffice_statistics_status_filter(pub):
     create_superuser(pub)
     create_environment(pub)
@@ -6386,7 +6431,7 @@ def test_backoffice_custom_view(pub):
     resp.forms['listing-settings']['user-label'].checked = False
     resp = resp.forms['listing-settings'].submit()
     # filters
-    resp.forms[0]['filter-2'].checked = True
+    resp.forms['listing-settings']['filter-2'].checked = True
     resp = resp.forms['listing-settings'].submit()
 
     resp.forms['listing-settings']['filter-2-value'] = 'baz'
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index 2477a4f06..be7845782 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -1634,19 +1634,32 @@ class FormPage(Directory):
     def listing_top_actions(self):
         return ''
 
-    def get_multi_actions(self, user):
-        # filter global manual actions to get those that can be run by the
-        # user, either because of actual roles, or because the action is
-        # accessible to functions.
+    def get_multi_actions(self, user, status_filter):
         global_actions = self.formdef.workflow.get_global_manual_actions()
+
+        if status_filter not in ('open', 'waiting', 'done', 'all'):
+            # when the listing is filtered on a specific status, include
+            # manual jumps with identifiers
+            try:
+                status = self.formdef.workflow.get_status(status_filter)
+            except KeyError:
+                status = None
+            else:
+                global_actions.extend(status.get_status_manual_actions())
+
         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 []]
+            # filter actions to get those that can be run by the user,
+            # either because of actual roles, or because the action is
+            # accessible to functions.
+            if not logged_users_role().id in action_dict.get('roles') or []:
+                action_dict['roles'] = [x for x in user.get_roles() if x in action_dict.get('roles') or []]
             if action_dict['roles']:
                 # action is accessible with user roles, remove mentions of functions
                 action_dict['functions'] = []
             if action_dict['functions'] or action_dict['roles']:
                 mass_actions.append(action_dict)
+
         return mass_actions
 
     def _q_index(self):
@@ -1680,19 +1693,22 @@ class FormPage(Directory):
         if get_request().get_query():
             qs = '?' + get_request().get_query()
 
-        multi_actions = self.get_multi_actions(get_request().user)
+        multi_actions = self.get_multi_actions(get_request().user,
+                status_filter=selected_filter)
+        multi_form = Form(id='multi-actions')
+        for action in multi_actions:
+            attrs = {}
+            if action.get('functions'):
+                for function in action.get('functions'):
+                    # dashes are replaced by underscores to prevent HTML5
+                    # normalization to CamelCase.
+                    attrs['data-visible_for_%s' % function.replace('-', '_')] = 'true'
+            else:
+                attrs['data-visible_for_all'] = 'true'
+            if getattr(action['action'], 'require_confirmation', False):
+                attrs['data-ask-for-confirmation'] = 'true'
+            multi_form.add_submit('button-action-%s' % action['action'].id, action['action'].name, attrs=attrs)
         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'):
-                        # dashes are replaced by underscores to prevent HTML5
-                        # normalization to CamelCase.
-                        attrs['data-visible_for_%s' % function.replace('-', '_')] = '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:
@@ -1707,13 +1723,17 @@ class FormPage(Directory):
                         limit=int(limit), offset=int(offset), query=query,
                         order_by=order_by, criterias=criterias,
                         include_checkboxes=bool(multi_actions))
+
         if get_response().status_code == 302:
             # catch early redirect
             return table
 
+        multi_form.widgets.append(HtmlWidget(table))
+        if not multi_actions:
+            multi_form.widgets.append(HtmlWidget('<div class="buttons"></div>'))
         if get_request().form.get('ajax') == 'true':
             get_response().filter = {'raw': True}
-            return table
+            return multi_form.render()
 
         view_name = self.view.title if self.view else _('Listing')
         html_top('management', '%s - %s' % (view_name, self.formdef.name))
@@ -1723,11 +1743,7 @@ class FormPage(Directory):
         r += get_session().display_message()
         r += self.listing_top_actions()
         r += htmltext('</div>')
-        if multi_actions:
-            multi_form.widgets.append(HtmlWidget(table))
-            r += multi_form.render()
-        else:
-            r += table
+        r += multi_form.render()
 
         get_response().filter['sidebar'] = self.get_formdata_sidebar(qs) + \
                 self.get_fields_sidebar(selected_filter, fields, limit=limit,
@@ -1753,7 +1769,13 @@ class FormPage(Directory):
                     publisher.substitutions.feed(publisher)
                     publisher.substitutions.feed(self.formdef)
                     publisher.substitutions.feed(formdata)
-                    formdata.perform_global_action(self.action['action'].id, self.user)
+                    if getattr(self.action['action'], 'status_action', False):
+                        # manual jump action
+                        from wcs.wf.jump import jump_and_perform
+                        jump_and_perform(formdata, self.action['action'].action)
+                    else:
+                        # global action
+                        formdata.perform_global_action(self.action['action'].id, self.user)
 
         item_ids = get_request().form['select[]']
         if '_all' in item_ids:
diff --git a/wcs/qommon/static/js/wcs.listing.js b/wcs/qommon/static/js/wcs.listing.js
index d2b391bbd..519505aca 100644
--- a/wcs/qommon/static/js/wcs.listing.js
+++ b/wcs/qommon/static/js/wcs.listing.js
@@ -151,9 +151,20 @@ $(document).on('backoffice-filter-change', function(event, listing_settings) {
     beforeSend: function() { $('#more-user-links, #listing, #statistics').addClass('activity'); },
     complete: function() { $('#more-user-links, #listing, #statistics').removeClass('activity'); },
     success: function(html) {
-      $('#page-links').remove();
-      $('#listing').replaceWith(html);
-      $('#statistics').replaceWith(html);
+      var $html = $(html);
+      var $listing = $html;
+      console.log('hello', $html.find('div.buttons'));
+      if ($listing.is('form')) {
+        // mass action
+        $listing = $listing.find('#listing');
+        $('#multi-actions div.buttons').replaceWith($html.find('div.buttons'));
+        $('#listing').replaceWith($listing);
+        $('#page-links').replaceWith($html.find('#page-links'));
+      } else {
+        $('#page-links').remove();
+        $('#listing').replaceWith($listing);
+      }
+      $('#statistics').replaceWith($html);
       if (typeof(wcs_draw_graphs) !== 'undefined') {
         wcs_draw_graphs();
       }
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 6cb4a8418..7ebe957f1 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -468,7 +468,6 @@ class Workflow(StorableObject):
                 actions.append({'action': action, 'roles': roles, 'functions': functions})
         return actions
 
-
     def get_global_actions_for_user(self, formdata, user):
         if not user:
             return []
@@ -1634,6 +1633,32 @@ class WorkflowStatus(object):
                 waitpoint = item.waitpoint or waitpoint
         return bool(endpoint or waitpoint)
 
+    def get_status_manual_actions(self):
+        actions = []
+        status_id = self.id
+
+        class StatusAction:
+            def __init__(self, action):
+                self.id = 'st-%s' % action.identifier
+                self.status_id = status_id
+                self.action_id = action.identifier
+                self.name = action.get_label()
+                self.status_action = True
+                self.require_confirmation = action.require_confirmation
+                self.action = action
+
+        for action in self.items or []:
+            if not isinstance(action, ChoiceWorkflowStatusItem):
+                continue
+            if not action.identifier:
+                continue
+            roles = action.by
+            functions = [x for x in roles if x in self.parent.roles]
+            roles = [x for x in roles if x not in self.parent.roles]
+            if functions or roles:
+                actions.append({'action': StatusAction(action), 'roles': roles, 'functions': functions})
+        return actions
+
     def get_contrast_color(self):
         colour = self.colour or 'ffffff'
         return misc.get_foreground_colour(colour)
-- 
2.26.2

