From 9b4f0e382faf6c96dde4e591d925568303f7432a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 20 Aug 2022 18:08:04 +0200
Subject: [PATCH] workflows: allow interactive actions in global actions
 (#16782)

---
 tests/backoffice_pages/test_all.py            | 133 +++++++++++++
 tests/backoffice_pages/test_carddata.py       |  64 +++++++
 tests/form_pages/test_all.py                  |  62 ++++++
 tests/workflow/test_all.py                    |  44 ++---
 wcs/admin/workflows.py                        |   4 +-
 wcs/backoffice/data_management.py             |   1 +
 wcs/backoffice/management.py                  |   9 +
 wcs/carddef.py                                |   4 +-
 wcs/formdata.py                               |  10 +-
 wcs/formdef.py                                |   4 +-
 wcs/forms/actions.py                          | 162 +++++++++++++++-
 wcs/forms/common.py                           |   5 +-
 .../backoffice/global-interactive-action.html |  14 ++
 .../wcs/global-interactive-action.html        |   5 +
 .../includes/global-interactive-action.html   |   6 +
 wcs/wf/attachment.py                          |   5 +-
 wcs/wf/choice.py                              |  15 +-
 wcs/wf/comment.py                             |   5 +-
 wcs/wf/display_message.py                     |  41 ++--
 wcs/wf/export_to_model.py                     |   3 +
 wcs/wf/form.py                                |   5 +-
 wcs/workflows.py                              | 181 ++++++++++++------
 22 files changed, 664 insertions(+), 118 deletions(-)
 create mode 100644 wcs/templates/wcs/backoffice/global-interactive-action.html
 create mode 100644 wcs/templates/wcs/global-interactive-action.html
 create mode 100644 wcs/templates/wcs/includes/global-interactive-action.html

diff --git a/tests/backoffice_pages/test_all.py b/tests/backoffice_pages/test_all.py
index c04181678..695ece92e 100644
--- a/tests/backoffice_pages/test_all.py
+++ b/tests/backoffice_pages/test_all.py
@@ -846,6 +846,79 @@ def test_backoffice_multi_actions_using_session_user(pub):
         assert 'session_user=admin' in content
 
 
+def test_backoffice_multi_actions_interactive(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 'id="multi-actions"' in resp.text  # always there
+
+    workflow = Workflow.get_default_workflow()
+    workflow.id = '2'
+    action = workflow.add_global_action('FOOBAR')
+
+    form_action = action.add_action('form')
+    form_action.varname = 'blah'
+    form_action.formdef = WorkflowFormFieldsFormDef(item=form_action)
+    form_action.formdef.fields.append(
+        fields.StringField(id='1', label='Test', varname='test', type='string', required=True)
+    )
+    register_comment = action.add_action('register-comment')
+    register_comment.comment = 'HELLO {{ form_workflow_form_blah_var_test }}'
+
+    trigger = action.triggers[0]
+    trigger.roles = [x.id for x in pub.role_class.select() if x.name == 'foobar']
+    workflow.store()
+
+    formdef.workflow_id = workflow.id
+    formdef.store()
+
+    resp = app.get('/backoffice/management/form-title/?limit=20')
+    assert 'id="multi-actions"' in resp.text
+    for checkbox in resp.forms[0].fields['select[]'][1:6]:
+        checkbox.checked = True
+    resp = resp.forms[0].submit('button-action-1')
+    assert '/actions/' in resp.location
+    resp = resp.follow()
+    resp = resp.follow()  # back to form listing
+    assert 'Error: empty action' in resp.text
+
+    form_action.by = trigger.roles
+    workflow.store()
+
+    resp = app.get('/backoffice/management/form-title/?limit=20')
+    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 '/actions/' in resp.location
+    resp = resp.follow()
+    assert '5 selected forms' in resp.text
+    resp = resp.form.submit('submit')
+    assert resp.pyquery('#form_error_fblah_1').text() == 'required field'
+    resp.form['fblah_1'] = 'GLOBAL INTERACTIVE ACTION'
+    resp = resp.form.submit('submit')
+
+    assert '?job=' in resp.location
+    resp = resp.follow()
+    assert 'Executing task &quot;FOOBAR&quot; on forms' in resp.text
+    assert '>completed<' in resp.text
+    assert (
+        resp.pyquery.find('[data-redirect-auto]').attr['href']
+        == '/backoffice/management/form-title/?limit=20'
+    )
+    for id in ids:
+        pub.substitutions.reset()
+        pub.substitutions.feed(formdef.data_class().get(id))
+        context = pub.substitutions.get_context_variables(mode='lazy')
+        assert context['form_number_raw'] == id
+        assert context['form_workflow_form_blah_var_test'].get_value() == 'GLOBAL INTERACTIVE ACTION'
+
+
 def test_backoffice_map(pub):
     create_user(pub)
     create_environment(pub)
@@ -1072,6 +1145,66 @@ def test_backoffice_global_action_jump_to_current_status(pub):
     assert resp.text.count('WORKFLOW COMMENT') == 2
 
 
+def test_backoffice_global_interactive_action(pub):
+    create_user(pub)
+
+    formdef = FormDef()
+    formdef.name = 'test global action'
+    formdef.fields = []
+
+    workflow = Workflow.get_default_workflow()
+    workflow.id = '2'
+    action = workflow.add_global_action('FOOBAR')
+
+    display = action.add_action('displaymsg')
+    display.message = 'This is a message'
+    display.to = []
+
+    form_action = action.add_action('form')
+    form_action.varname = 'blah'
+    form_action.formdef = WorkflowFormFieldsFormDef(item=form_action)
+    form_action.formdef.fields.append(
+        fields.StringField(id='1', label='Test', varname='test', type='string', required=True)
+    )
+    register_comment = action.add_action('register-comment')
+    register_comment.comment = 'HELLO {{ form_workflow_form_blah_var_test }}'
+    trigger = action.triggers[0]
+    trigger.roles = [x.id for x in pub.role_class.select() if x.name == 'foobar']
+
+    workflow.store()
+    formdef.workflow_id = workflow.id
+    formdef.workflow_roles = {'_receiver': 1}
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+
+    app = login(get_app(pub))
+    resp = app.get(formdata.get_url(backoffice=True))
+    assert 'button-action-1' in resp.form.fields
+    resp = resp.form.submit('button-action-1')
+    resp = resp.follow()  # -> error, empty action
+    resp = resp.follow()  # -> back to form
+    assert 'Error: empty action' in resp.text
+
+    form_action.by = trigger.roles
+    workflow.store()
+
+    resp = app.get(formdata.get_url(backoffice=True))
+    resp = resp.form.submit('button-action-1')
+    resp = resp.follow()
+    assert 'This is a message' in resp.text
+    resp = resp.form.submit('submit')
+    assert resp.pyquery('#form_error_fblah_1').text() == 'required field'
+    resp.form['fblah_1'] = 'GLOBAL INTERACTIVE ACTION'
+    resp = resp.form.submit('submit')
+    assert resp.location == formdata.get_url(backoffice=True)
+    resp = resp.follow()
+
+    assert 'HELLO GLOBAL INTERACTIVE ACTION' in resp.text
+
+
 def test_backoffice_submission_context(pub):
     user = create_user(pub)
     create_environment(pub)
diff --git a/tests/backoffice_pages/test_carddata.py b/tests/backoffice_pages/test_carddata.py
index b657e6239..c5f332d15 100644
--- a/tests/backoffice_pages/test_carddata.py
+++ b/tests/backoffice_pages/test_carddata.py
@@ -13,6 +13,7 @@ from wcs.carddef import CardDef
 from wcs.categories import CardDefCategory
 from wcs.formdef import FormDef
 from wcs.qommon.http_request import HTTPRequest
+from wcs.wf.form import WorkflowFormFieldsFormDef
 from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef
 
 from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login
@@ -1142,3 +1143,66 @@ def test_carddata_add_related(pub):
     assert 'Add another Child' not in resp
     assert resp.text.count('/backoffice/data/adult/add/?_popup=1') == 1
     assert '/backoffice/data/child/add/?_popup=1' not in resp
+
+
+def test_backoffice_card_global_interactive_action(pub):
+    user = create_user(pub)
+
+    workflow = CardDef.get_default_workflow()
+    workflow.id = None
+    action = workflow.add_global_action('FOOBAR')
+
+    display = action.add_action('displaymsg')
+    display.message = 'This is a message'
+    display.to = []
+
+    form_action = action.add_action('form')
+    form_action.varname = 'blah'
+    form_action.formdef = WorkflowFormFieldsFormDef(item=form_action)
+    form_action.formdef.fields.append(
+        fields.StringField(id='1', label='Test', varname='test', type='string', required=True)
+    )
+    register_comment = action.add_action('register-comment')
+    register_comment.comment = 'HELLO {{ form_workflow_form_blah_var_test }}'
+    trigger = action.triggers[0]
+    trigger.roles = [user.roles[0]]
+
+    workflow.store()
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'foo'
+    carddef.fields = []
+    carddef.workflow_id = workflow.id
+    carddef.workflow_roles = {'_editor': user.roles[0]}
+    carddef.store()
+
+    carddef.data_class().wipe()
+    carddata = carddef.data_class()()
+    carddata.data = {}
+    carddata.just_created()
+    carddata.store()
+
+    app = login(get_app(pub))
+    resp = app.get(carddata.get_url(backoffice=True))
+    assert 'button-action-1' in resp.form.fields
+    resp = resp.form.submit('button-action-1')
+    resp = resp.follow()  # -> error, empty action
+    resp = resp.follow()  # -> back to form
+    assert 'Error: empty action' in resp.text
+
+    form_action.by = trigger.roles
+    workflow.store()
+
+    resp = app.get(carddata.get_url(backoffice=True))
+    resp = resp.form.submit('button-action-1')
+    resp = resp.follow()
+    assert 'This is a message' in resp.text
+    resp = resp.form.submit('submit')
+    assert resp.pyquery('#form_error_fblah_1').text() == 'required field'
+    resp.form['fblah_1'] = 'GLOBAL INTERACTIVE ACTION'
+    resp = resp.form.submit('submit')
+    assert resp.location == carddata.get_url(backoffice=True)
+    resp = resp.follow()
+
+    assert 'HELLO GLOBAL INTERACTIVE ACTION' in resp.text
diff --git a/tests/form_pages/test_all.py b/tests/form_pages/test_all.py
index 16d3e9193..404f0841e 100644
--- a/tests/form_pages/test_all.py
+++ b/tests/form_pages/test_all.py
@@ -9752,3 +9752,65 @@ def test_go_to_backoffice(pub):
     app = get_app(pub)
     resp = app.get('/test/go-to-backoffice')
     assert resp.location.endswith('/backoffice/forms/%s/' % formdef.id)
+
+
+def test_global_interactive_action(pub):
+    user = create_user(pub)
+
+    formdef = FormDef()
+    formdef.name = 'test global action'
+    formdef.fields = []
+
+    workflow = Workflow.get_default_workflow()
+    workflow.id = '2'
+    action = workflow.add_global_action('FOOBAR')
+
+    display = action.add_action('displaymsg')
+    display.message = 'This is a message'
+    display.to = []
+
+    form_action = action.add_action('form')
+    form_action.varname = 'blah'
+    form_action.formdef = WorkflowFormFieldsFormDef(item=form_action)
+    form_action.formdef.fields.append(
+        fields.StringField(id='1', label='Test', varname='test', type='string', required=True)
+    )
+    register_comment = action.add_action('register-comment')
+    register_comment.comment = 'HELLO {{ form_workflow_form_blah_var_test }}'
+    trigger = action.triggers[0]
+    trigger.roles = ['_submitter']
+
+    workflow.store()
+    formdef.workflow_id = workflow.id
+    formdef.workflow_roles = {'_receiver': 1}
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.user_id = user.id
+    formdata.just_created()
+    formdata.perform_workflow()
+    formdata.store()
+
+    app = login(get_app(pub), username='foo', password='foo')
+    resp = app.get(formdata.get_url(backoffice=False))
+    assert 'button-action-1' in resp.form.fields
+    resp = resp.form.submit('button-action-1')
+    resp = resp.follow()  # -> error, empty action
+    resp = resp.follow()  # -> back to form
+    assert 'Error: empty action' in resp.text
+
+    form_action.by = trigger.roles
+    workflow.store()
+
+    resp = app.get(formdata.get_url(backoffice=False))
+    resp = resp.form.submit('button-action-1')
+    resp = resp.follow()
+    assert 'This is a message' in resp.text
+    resp = resp.form.submit('submit')
+    assert resp.pyquery('#form_error_fblah_1').text() == 'required field'
+    resp.form['fblah_1'] = 'GLOBAL INTERACTIVE ACTION'
+    resp = resp.form.submit('submit')
+    assert resp.location == formdata.get_url(backoffice=False)
+    resp = resp.follow()
+
+    assert 'HELLO GLOBAL INTERACTIVE ACTION' in resp.text
diff --git a/tests/workflow/test_all.py b/tests/workflow/test_all.py
index dec3779af..437499154 100644
--- a/tests/workflow/test_all.py
+++ b/tests/workflow/test_all.py
@@ -3025,25 +3025,25 @@ def test_workflow_display_message(pub):
     display_message.parent = st1
 
     display_message.message = 'test'
-    assert display_message.get_message(formdata) == 'test'
+    assert display_message.get_message(formdata) == '<p>test</p>'
 
     display_message.message = '{{ number }}'
-    assert display_message.get_message(formdata) == str(formdata.id)
+    assert display_message.get_message(formdata) == '<p>%s</p>' % formdata.id
 
     display_message.message = '[number]'
-    assert display_message.get_message(formdata) == str(formdata.id)
+    assert display_message.get_message(formdata) == '<p>%s</p>' % formdata.id
 
     display_message.message = '{{ bar }}'
-    assert display_message.get_message(formdata) == 'Foobar'
+    assert display_message.get_message(formdata) == '<p>Foobar</p>'
 
     display_message.message = '[bar]'
-    assert display_message.get_message(formdata) == 'Foobar'
+    assert display_message.get_message(formdata) == '<p>Foobar</p>'
 
     # makes sure the string is correctly escaped for HTML
     display_message.message = '{{ foo }}'
-    assert display_message.get_message(formdata) == '1 &lt; 3'
+    assert display_message.get_message(formdata) == '<p>1 &lt; 3</p>'
     display_message.message = '[foo]'
-    assert display_message.get_message(formdata) == '1 &lt; 3'
+    assert display_message.get_message(formdata) == '<p>1 &lt; 3</p>'
 
 
 def test_workflow_display_message_to(pub):
@@ -3072,8 +3072,8 @@ def test_workflow_display_message_to(pub):
 
     display_message.message = 'all'
     display_message.to = None
-    assert display_message.get_message(formdata) == 'all'
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['all']
+    assert display_message.get_message(formdata) == '<p>all</p>'
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>all</p>']
 
     display_message.message = 'to-role'
     display_message.to = [role.id]
@@ -3086,8 +3086,8 @@ def test_workflow_display_message_to(pub):
     assert display_message.get_message(formdata) == ''
     assert formdata.get_workflow_messages(user=pub._request._user) == []
     user.roles = [role.id]
-    assert display_message.get_message(formdata) == 'to-role'
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['to-role']
+    assert display_message.get_message(formdata) == '<p>to-role</p>'
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>to-role</p>']
 
     user.roles = []
     display_message.message = 'to-submitter'
@@ -3095,22 +3095,22 @@ def test_workflow_display_message_to(pub):
     assert display_message.get_message(formdata) == ''
     assert formdata.get_workflow_messages(user=pub._request._user) == []
     formdata.user_id = user.id
-    assert display_message.get_message(formdata) == 'to-submitter'
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['to-submitter']
+    assert display_message.get_message(formdata) == '<p>to-submitter</p>'
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>to-submitter</p>']
 
     display_message.message = 'to-role-or-submitter'
     display_message.to = [role.id, '_submitter']
-    assert display_message.get_message(formdata) == 'to-role-or-submitter'
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['to-role-or-submitter']
+    assert display_message.get_message(formdata) == '<p>to-role-or-submitter</p>'
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>to-role-or-submitter</p>']
     formdata.user_id = None
     assert display_message.get_message(formdata) == ''
     assert formdata.get_workflow_messages(user=pub._request._user) == []
     user.roles = [role.id]
-    assert display_message.get_message(formdata) == 'to-role-or-submitter'
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['to-role-or-submitter']
+    assert display_message.get_message(formdata) == '<p>to-role-or-submitter</p>'
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>to-role-or-submitter</p>']
     formdata.user_id = user.id
-    assert display_message.get_message(formdata) == 'to-role-or-submitter'
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['to-role-or-submitter']
+    assert display_message.get_message(formdata) == '<p>to-role-or-submitter</p>'
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>to-role-or-submitter</p>']
 
     display_message.to = [role2.id]
     assert display_message.get_message(formdata) == ''
@@ -3120,10 +3120,10 @@ def test_workflow_display_message_to(pub):
     display_message2 = st1.add_action('displaymsg')
     display_message2.message = 'd2'
     display_message2.to = [role.id, '_submitter']
-    assert formdata.get_workflow_messages(user=pub._request._user) == ['d2']
+    assert formdata.get_workflow_messages(user=pub._request._user) == ['<p>d2</p>']
     user.roles = [role.id, role2.id]
-    assert 'd1' in formdata.get_workflow_messages(user=pub._request._user)
-    assert 'd2' in formdata.get_workflow_messages(user=pub._request._user)
+    assert '<p>d1</p>' in formdata.get_workflow_messages(user=pub._request._user)
+    assert '<p>d2</p>' in formdata.get_workflow_messages(user=pub._request._user)
 
 
 def test_workflow_display_message_line_details(pub):
diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py
index 39cb7cc00..8124f7d31 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -702,7 +702,9 @@ class WorkflowStatusPage(Directory):
         available_items.sort(key=lambda x: misc.simplify(x.description))
 
         for category, category_label in categories:
-            options = [(x.key, x.description) for x in available_items if x.category == category]
+            options = [
+                (x.key, x(parent=self.status).description) for x in available_items if x.category == category
+            ]
             form.add(
                 SingleSelectWidget,
                 'action-%s' % category,
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index dce24c3b9..bf1a4fa03 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -114,6 +114,7 @@ class CardPage(FormPage):
         'map',
         'geojson',
         'add',
+        'actions',
         ('export-spreadsheet', 'export_spreadsheet'),
         ('save-view', 'save_view'),
         ('delete-view', 'delete_view'),
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index ea2cb1344..d4c0c720c 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -764,6 +764,7 @@ class FormPage(Directory):
         'export',
         'map',
         'geojson',
+        'actions',
         ('export-spreadsheet', 'export_spreadsheet'),
         ('filter-options', 'filter_options'),
         ('save-view', 'save_view'),
@@ -795,6 +796,9 @@ class FormPage(Directory):
             if update_breadcrumbs:
                 get_response().breadcrumb.append((view.get_url_slug() + '/', view.title))
         self.set_default_view()
+        from wcs.forms.actions import ActionsDirectory
+
+        self.actions = ActionsDirectory()
 
     def set_default_view(self):
         if not get_request():
@@ -2215,6 +2219,11 @@ class FormPage(Directory):
                 'receipt_time', [Contains('id', [int(x) for x in item_ids])]
             )
 
+        if action['action'].is_interactive():
+            return redirect(
+                action['action'].get_global_interactive_form_url(formdef=self.formdef, ids=item_ids)
+            )
+
         job = get_response().add_after_job(
             MassActionAfterJob(
                 label=_('Executing task "%s" on forms') % action['action'].name,
diff --git a/wcs/carddef.py b/wcs/carddef.py
index 4bf74d883..7b84046d0 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -26,7 +26,7 @@ from wcs.carddata import CardData
 from wcs.categories import CardDefCategory
 from wcs.formdef import FormDef, FormDefDoesNotExist, get_formdefs_of_all_kinds
 
-from .qommon import _, force_text, misc
+from .qommon import _, force_text, misc, pgettext_lazy
 from .qommon.storage import ElementEqual, ElementILike, Equal, Null, StrictNotEqual
 
 if not hasattr(types, 'ClassType'):
@@ -46,6 +46,8 @@ class CardDef(FormDef):
     xml_root_node = 'carddef'
     verbose_name = _('Card model')
     verbose_name_plural = _('Card models')
+    item_name = pgettext_lazy('item', 'card')
+    item_name_plural = pgettext_lazy('item', 'cards')
 
     confirmation = False
 
diff --git a/wcs/formdata.py b/wcs/formdata.py
index c78dca995..14b7e8eee 100644
--- a/wcs/formdata.py
+++ b/wcs/formdata.py
@@ -672,15 +672,7 @@ class FormData(StorableObject):
         wf_status = self.get_visible_status(user=user)
         if not wf_status:
             return []
-        messages = []
-        for item in wf_status.items:
-            if not item.check_condition(self):
-                continue
-            if hasattr(item, 'get_message'):
-                message = item.get_message(self, position=position)
-                if message:
-                    messages.append(message)
-        return messages
+        return wf_status.get_messages(formdata=self, position=position)
 
     def get_status(self, status=None):
         if not status:
diff --git a/wcs/formdef.py b/wcs/formdef.py
index bb8a33b5f..f9b092440 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -38,7 +38,7 @@ from quixote.http_request import Upload
 from . import data_sources, fields
 from .categories import Category
 from .formdata import FormData
-from .qommon import PICKLE_KWARGS, _, force_str, get_cfg
+from .qommon import PICKLE_KWARGS, _, force_str, get_cfg, pgettext_lazy
 from .qommon.admin.emails import EmailsDirectory
 from .qommon.cron import CronJob
 from .qommon.form import Form, HtmlWidget, UploadedFile
@@ -126,6 +126,8 @@ class FormDef(StorableObject):
     backoffice_section = 'forms'
     verbose_name = _('Form')
     verbose_name_plural = _('Forms')
+    item_name = pgettext_lazy('item', 'form')
+    item_name_plural = pgettext_lazy('item', 'forms')
 
     name = None
     description = None
diff --git a/wcs/forms/actions.py b/wcs/forms/actions.py
index 3a2df270b..827cf9d6e 100644
--- a/wcs/forms/actions.py
+++ b/wcs/forms/actions.py
@@ -14,17 +14,20 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
-from quixote import get_publisher, redirect
+from quixote import get_publisher, get_request, get_response, get_session, redirect
 from quixote.directory import Directory
 from quixote.errors import PublishError
 
 from wcs.carddef import CardDef
 from wcs.formdef import FormDef
 from wcs.forms.common import FormTemplateMixin
+from wcs.qommon import _, errors, misc, template
+from wcs.qommon.afterjobs import AfterJob
+from wcs.qommon.backoffice.menu import html_top as backoffice_html_top
+from wcs.qommon.form import Form
+from wcs.qommon.http_request import HTTPRequest
 from wcs.wf.jump import jump_and_perform
-
-from ..qommon import _, errors, misc, template
-from ..qommon.form import Form
+from wcs.workflows import perform_items
 
 
 class MissingOrExpiredToken(PublishError):
@@ -45,9 +48,11 @@ class ActionsDirectory(Directory):
             token = get_publisher().token_class.get(component)
         except KeyError:
             raise MissingOrExpiredToken()
-        if token.type != 'action':
-            raise errors.TraversalError()
-        return ActionDirectory(token)
+        if token.type == 'action':
+            return ActionDirectory(token)
+        if token.type == 'global-interactive-action':
+            return GlobalInteractiveActionDirectory(token)
+        raise errors.TraversalError()
 
 
 class ActionDirectory(Directory, FormTemplateMixin):
@@ -108,3 +113,146 @@ class ActionDirectory(Directory, FormTemplateMixin):
         return template.QommonTemplateResponse(
             templates=list(self.get_formdef_template_variants(self.templates)), context=context
         )
+
+
+class GlobalInteractiveActionDirectory(Directory, FormTemplateMixin):
+    _q_exports = ['']
+
+    def __init__(self, token):
+        self.token = token
+        formdef_type = self.token.context.get('form_type', 'formdef')
+        if formdef_type == 'carddef':
+            formdef_class = CardDef
+        elif formdef_type == 'formdef':
+            formdef_class = FormDef
+        else:
+            raise errors.TraversalError()
+
+        self.formdef = formdef_class.get_by_urlname(self.token.context['form_slug'])
+
+        try:
+            self.formdata = self.formdef.data_class().get(self.token.context['form_ids'][0])
+        except KeyError:
+            raise MissingFormdata()
+
+        self.action = None
+        for action in self.formdef.workflow.global_actions or []:
+            if action.id == self.token.context['action_id']:
+                self.action = action
+                break
+        else:
+            raise MissingOrExpiredToken()
+
+    def _q_index(self):
+        if get_request().is_in_backoffice():
+            if isinstance(self.formdef, CardDef):
+                section = 'data_management'
+            else:
+                section = 'management'
+            get_response().breadcrumb.append(('', self.action.name))
+            backoffice_html_top(section, title=self.formdef.name)
+            template_name = 'wcs/backoffice/global-interactive-action.html'
+        else:
+            template.html_top(title=self.formdef.name)
+            template_name = 'wcs/global-interactive-action.html'
+        form = self.action.get_action_form(self.formdata, user=get_request().user)
+        if not form:
+            # empty form, nothing to do
+            get_session().message = ('error', _('Error: empty action'))
+            return redirect(self.token.context['return_url'])
+        if not form.is_submitted() or form.has_errors():
+            messages = self.action.get_messages()
+            context = {
+                'html_form': form,
+                'action': self.action,
+                'ids': self.token.context['form_ids'],
+                'formdata': self.formdata,
+                'workflow_messages': messages,
+            }
+            return template.QommonTemplateResponse(templates=[template_name], context=context)
+
+        if len(self.token.context['form_ids']) > 1:
+            # mass action
+            job = get_response().add_after_job(
+                GlobalInteractiveMassActionAfterJob(
+                    label=_('Executing task "%s" on forms') % self.action.name,
+                    formdef=self.formdef,
+                    request_form=get_request().form,
+                    user_id=get_request().user.id,
+                    action_id=self.action.id,
+                    item_ids=self.token.context['form_ids'],
+                    return_url=self.token.context['return_url'],
+                )
+            )
+            job.store()
+            self.token.remove_self()
+            return redirect(job.get_processing_url())
+
+        get_publisher().substitutions.reset()
+        get_publisher().substitutions.feed(get_publisher())
+        get_publisher().substitutions.feed(self.formdata)
+        status = self.formdata.status
+        url = self.action.handle_form(form, self.formdata, user=get_request().user)
+        if self.formdata.status == status:
+            # if there's no status change run non-interactive items from global ction
+            url = (
+                perform_items(
+                    self.action.items, self.formdata, event=('global-interactive-action', self.action.id)
+                )
+                or url
+            )
+        if not url:
+            url = self.formdata.get_url(backoffice=bool(get_request().is_in_backoffice()))
+        self.token.remove_self()
+        return redirect(url)
+
+
+class GlobalInteractiveMassActionAfterJob(AfterJob):
+    def __init__(self, formdef, **kwargs):
+        super().__init__(formdef_class=formdef.__class__, formdef_id=formdef.id, **kwargs)
+
+    def execute(self):
+        self.total_count = len(self.kwargs['item_ids'])
+
+        # restore request form
+        publisher = get_publisher()
+        req = HTTPRequest(None, {'SERVER_NAME': publisher.tenant.hostname, 'SCRIPT_NAME': ''})
+        req.form = self.kwargs['request_form']
+
+        formdef = self.kwargs['formdef_class'].get(self.kwargs['formdef_id'])
+        data_class = formdef.data_class()
+
+        for action in formdef.workflow.global_actions or []:
+            if action.id == self.kwargs['action_id']:
+                break
+        else:
+            # maybe action got removed from workflow?
+            return
+
+        user = publisher.user_class.get(self.kwargs['user_id'])
+        for item_id in self.kwargs['item_ids']:
+            publisher._set_request(req)
+            publisher.substitutions.reset()
+            publisher.substitutions.feed(get_publisher())
+            formdata = data_class.get(item_id)
+            publisher.substitutions.feed(formdata)
+
+            status = formdata.status
+            form = action.get_action_form(formdata, user=user)
+            form.method = 'get'
+            action.handle_form(form, formdata, user=user)
+            # reset request to avoid emails being created as afterjobs
+            publisher._set_request(None)
+            if formdata.status == status:
+                # if there's no status change run non-interactive items from global ction
+                perform_items(action.items, formdata, event=('global-interactive-action', action.id))
+            self.increment_count()
+
+    def done_action_url(self):
+        return self.kwargs['return_url']
+
+    def done_action_label(self):
+        return _('Back to Listing')
+
+    def done_button_attributes(self):
+        return {'data-redirect-auto': 'true'}
diff --git a/wcs/forms/common.py b/wcs/forms/common.py
index 6b35fbb6b..17b5f865b 100644
--- a/wcs/forms/common.py
+++ b/wcs/forms/common.py
@@ -226,10 +226,7 @@ class FormStatusPage(Directory, FormTemplateMixin):
                 else:
                     r += htmltext('<div class="workflow-messages %s">' % position)
                 for workflow_message in workflow_messages:
-                    if workflow_message.startswith('<'):
-                        r += htmltext(workflow_message)
-                    else:
-                        r += htmltext('<p>%s</p>' % workflow_message)
+                    r += htmltext(workflow_message)
                 r += htmltext('</div>')
                 return r.getvalue()
         return ''
diff --git a/wcs/templates/wcs/backoffice/global-interactive-action.html b/wcs/templates/wcs/backoffice/global-interactive-action.html
new file mode 100644
index 000000000..d5b63d223
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/global-interactive-action.html
@@ -0,0 +1,14 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{{ action.name }} -
+{% if ids|length == 1 %}
+{{ formdata.formdef.item_name }} - {{ formdata.id_display }}
+{% else %}
+{% blocktrans with len=ids|length item_name=formdata.formdef.item_name_plural %}{{ len }} selected {{ item_name }}{% endblocktrans %}
+{% endif %}
+{% endblock %}
+
+{% block content %}
+{% include "wcs/includes/global-interactive-action.html" %}
+{% endblock %}
diff --git a/wcs/templates/wcs/global-interactive-action.html b/wcs/templates/wcs/global-interactive-action.html
new file mode 100644
index 000000000..0de3d4ef5
--- /dev/null
+++ b/wcs/templates/wcs/global-interactive-action.html
@@ -0,0 +1,5 @@
+{% extends template_base %}
+
+{% block body %}
+{% include "wcs/includes/global-interactive-action.html" %}
+{% endblock %}
diff --git a/wcs/templates/wcs/includes/global-interactive-action.html b/wcs/templates/wcs/includes/global-interactive-action.html
new file mode 100644
index 000000000..125e77f20
--- /dev/null
+++ b/wcs/templates/wcs/includes/global-interactive-action.html
@@ -0,0 +1,6 @@
+{% if workflow_messages %}
+  <div class="workflow-messages">
+  {% for message in workflow_messages %}{{ message|safe }}{% endfor %}
+  </div>
+{% endif %}
+{{ html_form.render|safe }}
diff --git a/wcs/wf/attachment.py b/wcs/wf/attachment.py
index 80e40c38d..a2eea9caa 100644
--- a/wcs/wf/attachment.py
+++ b/wcs/wf/attachment.py
@@ -92,7 +92,7 @@ class AddAttachmentWorkflowStatusItem(WorkflowStatusItem):
     category = 'interaction'
     endpoint = False
     waitpoint = True
-    ok_in_global_action = False
+    ok_in_global_action = True
 
     title = None
     display_title = True
@@ -128,6 +128,9 @@ class AddAttachmentWorkflowStatusItem(WorkflowStatusItem):
         else:
             return _('not completed')
 
+    def is_interactive(self):
+        return True
+
     def fill_form(self, form, formdata, user, **kwargs):
         if self.display_title:
             title = self.title or _('Upload File')
diff --git a/wcs/wf/choice.py b/wcs/wf/choice.py
index d0f4f07d1..3540057e2 100644
--- a/wcs/wf/choice.py
+++ b/wcs/wf/choice.py
@@ -25,15 +25,14 @@ from wcs.qommon.form import (
     WidgetList,
     WysiwygTextWidget,
 )
-from wcs.workflows import WorkflowStatus, WorkflowStatusJumpItem, register_item_class
+from wcs.workflows import WorkflowGlobalAction, WorkflowStatus, WorkflowStatusJumpItem, register_item_class
 
 
 class ChoiceWorkflowStatusItem(WorkflowStatusJumpItem):
-    description = _('Manual Jump')
     key = 'choice'
     endpoint = False
     waitpoint = True
-    ok_in_global_action = False
+    ok_in_global_action = True
 
     label = None
     identifier = None
@@ -42,6 +41,13 @@ class ChoiceWorkflowStatusItem(WorkflowStatusJumpItem):
     require_confirmation = False
     ignore_form_errors = False
 
+    @property
+    def description(self):
+        if isinstance(self.parent, WorkflowGlobalAction):
+            return _('Manual Jump (interactive)')
+        else:
+            return _('Manual Jump')
+
     def get_label(self):
         expression = self.get_expression(self.label, allow_python=False, allow_ezt=False)
         if expression['type'] == 'text':
@@ -83,6 +89,9 @@ class ChoiceWorkflowStatusItem(WorkflowStatusJumpItem):
         if self.get_expression(self.label, allow_python=False, allow_ezt=False)['type'] != 'text':
             yield self.label
 
+    def is_interactive(self):
+        return True
+
     def fill_form(self, form, formdata, user, **kwargs):
         label = self.compute(self.label, allow_python=False, allow_ezt=False)
         if not label:
diff --git a/wcs/wf/comment.py b/wcs/wf/comment.py
index d2d9818c1..c65535583 100644
--- a/wcs/wf/comment.py
+++ b/wcs/wf/comment.py
@@ -52,7 +52,7 @@ class CommentableWorkflowStatusItem(WorkflowStatusItem):
     category = 'interaction'
     endpoint = False
     waitpoint = True
-    ok_in_global_action = False
+    ok_in_global_action = True
 
     required = False
     varname = None
@@ -68,6 +68,9 @@ class CommentableWorkflowStatusItem(WorkflowStatusItem):
         else:
             return _('not completed')
 
+    def is_interactive(self):
+        return True
+
     def fill_form(self, form, formdata, user, **kwargs):
         if 'comment' not in [x.name for x in form.widgets]:
             if self.label is None:
diff --git a/wcs/wf/display_message.py b/wcs/wf/display_message.py
index ce84c9d8c..543dcba92 100644
--- a/wcs/wf/display_message.py
+++ b/wcs/wf/display_message.py
@@ -22,7 +22,7 @@ from quixote.html import htmltext
 from wcs.qommon import _, ezt, misc
 from wcs.qommon.form import ComputedExpressionWidget, SingleSelectWidget, TextWidget, WidgetListOfRoles
 from wcs.qommon.template import Template
-from wcs.workflows import WorkflowStatusItem, register_item_class
+from wcs.workflows import WorkflowGlobalAction, WorkflowStatusItem, register_item_class
 
 
 class DisplayMessageWorkflowStatusItem(WorkflowStatusItem):
@@ -30,7 +30,7 @@ class DisplayMessageWorkflowStatusItem(WorkflowStatusItem):
     key = 'displaymsg'
     category = 'interaction'
     support_substitution_variables = True
-    ok_in_global_action = False
+    ok_in_global_action = True
 
     to = None
     position = 'top'
@@ -38,8 +38,11 @@ class DisplayMessageWorkflowStatusItem(WorkflowStatusItem):
     message = None
 
     def get_line_details(self):
+        in_global_action = isinstance(self.parent, WorkflowGlobalAction)
         parts = []
-        if self.position == 'top':
+        if in_global_action:
+            pass
+        elif self.position == 'top':
             parts.append(_('top of page'))
         elif self.position == 'bottom':
             parts.append(_('bottom of page'))
@@ -53,23 +56,34 @@ class DisplayMessageWorkflowStatusItem(WorkflowStatusItem):
         yield from super().get_computed_strings()
         yield self.message
 
-    def get_message(self, filled, position='top'):
-        if not (self.message and self.position == position and filled.is_for_current_user(self.to)):
+    def get_message(self, formdata, position='top'):
+        if not self.message:
             return ''
+        if formdata and not formdata.is_for_current_user(self.to):
+            return ''
+
+        in_global_action = isinstance(self.parent, WorkflowGlobalAction)
+        if self.position != position and not in_global_action:
+            return
 
-        dict = copy.copy(get_publisher().substitutions.get_context_variables('lazy'))
-        dict['date'] = misc.localstrftime(filled.receipt_time)
-        dict['number'] = filled.id
-        handling_role = filled.get_handling_role()
-        if handling_role and handling_role.details:
-            dict['receiver'] = handling_role.details.replace('\n', '<br />')
+        if formdata:
+            ctx = copy.copy(get_publisher().substitutions.get_context_variables('lazy'))
+            ctx['date'] = misc.localstrftime(formdata.receipt_time)
+            ctx['number'] = formdata.id
+            handling_role = formdata.get_handling_role()
+            if handling_role and handling_role.details:
+                ctx['receiver'] = handling_role.details.replace('\n', '<br />')
+        else:
+            ctx = get_publisher().substitutions.get_context_variables('lazy')
 
         message = self.message
         if self.level:
             message = '<div class="%snotice">%s</div>' % (self.level, message)
+        elif not message.startswith('<'):
+            message = '<p>%s</p>' % message
 
         try:
-            return Template(message, ezt_format=ezt.FORMAT_HTML).render(dict)
+            return Template(message, ezt_format=ezt.FORMAT_HTML).render(ctx)
         except Exception as e:
             get_publisher().record_error(
                 error_summary=_('Error in template of workflow message (%s)') % e, exception=e, notify=True
@@ -78,6 +92,7 @@ class DisplayMessageWorkflowStatusItem(WorkflowStatusItem):
 
     def add_parameters_widgets(self, form, parameters, prefix='', formdef=None, **kwargs):
         super().add_parameters_widgets(form, parameters, prefix=prefix, formdef=formdef, **kwargs)
+        in_global_action = isinstance(self.parent, WorkflowGlobalAction)
         if 'message' in parameters:
             form.add(
                 TextWidget,
@@ -102,7 +117,7 @@ class DisplayMessageWorkflowStatusItem(WorkflowStatusItem):
                     ('error', _('Error')),
                 ],
             )
-        if 'position' in parameters:
+        if 'position' in parameters and not in_global_action:
             form.add(
                 SingleSelectWidget,
                 '%sposition' % prefix,
diff --git a/wcs/wf/export_to_model.py b/wcs/wf/export_to_model.py
index 4242d376d..bf47420f3 100644
--- a/wcs/wf/export_to_model.py
+++ b/wcs/wf/export_to_model.py
@@ -263,6 +263,9 @@ class ExportToModel(WorkflowStatusItem):
         else:
             return _('no model set')
 
+    def is_interactive(self):
+        return bool(self.method == 'interactive')
+
     def fill_form(self, form, formdata, user, **kwargs):
         if self.method != 'interactive':
             return
diff --git a/wcs/wf/form.py b/wcs/wf/form.py
index 67d6459a9..1f77e7f4b 100644
--- a/wcs/wf/form.py
+++ b/wcs/wf/form.py
@@ -112,7 +112,7 @@ class FormWorkflowStatusItem(WorkflowStatusItem):
     description = _('Form')
     key = 'form'
     category = 'interaction'
-    ok_in_global_action = False
+    ok_in_global_action = True
     endpoint = False
     waitpoint = True
 
@@ -251,6 +251,9 @@ class FormWorkflowStatusItem(WorkflowStatusItem):
                 # already prefixed
                 pass
 
+    def is_interactive(self):
+        return True
+
     def fill_form(self, form, formdata, user, displayed_fields=None, **kwargs):
         if not self.formdef:
             return
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 5e6c6a43f..c855e7bd1 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -414,6 +414,7 @@ class ActionsTracingEvolutionPart(EvolutionPart):
             'global-action': _('Global action'),
             'global-action-timeout': _('Global action timeout'),
             'global-api-trigger': _('API Trigger'),
+            'global-interactive-action': _('Global action (interactive)'),
             'global-external-workflow': _('Trigger by external workflow'),
             'json-import-created': _('Created (by JSON import)'),
             'timeout-jump': _('Timeout jump'),
@@ -1730,6 +1731,82 @@ class SerieOfActionsMixin:
         for action in self.items or []:
             yield from action.get_dependencies()
 
+    def get_action_form(self, filled, user, displayed_fields=None):
+        form = Form(enctype='multipart/form-data', use_tokens=False)
+        form.attrs['id'] = 'wf-actions'
+        for item in self.items:
+            if not item.check_auth(filled, user):
+                continue
+            if not item.check_condition(filled):
+                continue
+            item.fill_form(form, filled, user, displayed_fields=displayed_fields)
+
+        if form.widgets or form.submit_widgets:
+            return form
+        else:
+            return None
+
+    def get_active_items(self, form, filled, user):
+        for item in self.items:
+            if hasattr(item, 'by'):
+                for role in item.by or []:
+                    if role == logged_users_role().id:
+                        break
+                    if role == '_submitter':
+                        if filled.is_submitter(user):
+                            break
+                        continue
+                    if user is None:
+                        continue
+                    if filled.get_function_roles(role).intersection(user.get_roles()):
+                        break
+                else:
+                    continue
+            if not item.check_condition(filled):
+                continue
+            yield item
+
+    def get_messages(self, formdata=None, position='top'):
+        messages = []
+        for item in self.items or []:
+            if not hasattr(item, 'get_message'):
+                continue
+            if not item.check_condition(formdata):
+                continue
+            message = item.get_message(formdata, position=position)
+            if message:
+                messages.append(message)
+        return messages
+
+    def handle_form(self, form, filled, user, evo):
+        evo.time = time.localtime()
+        if user:
+            if filled.is_submitter(user):
+                evo.who = '_submitter'
+            else:
+                evo.who = user.id
+        if not filled.evolution:
+            filled.evolution = []
+
+        for item in self.get_active_items(form, filled, user):
+            next_url = item.submit_form(form, filled, user, evo)
+            if next_url is True:
+                break
+            if next_url:
+                if not form.has_errors():
+                    if evo.parts or evo.status or evo.comment or evo.status:
+                        # add evolution entry only if there's some content
+                        # within, i.e. do not register anything in the case of
+                        # a single edit action (where the evolution should be
+                        # appended only after successful edit).
+                        filled.evolution.append(evo)
+                        if evo.status:
+                            filled.status = evo.status
+                        filled.store()
+                return next_url
+
+        return next_url
+
 
 class WorkflowGlobalAction(SerieOfActionsMixin):
     id = None
@@ -1747,6 +1824,38 @@ class WorkflowGlobalAction(SerieOfActionsMixin):
             del odict['parent']
         return odict
 
+    def is_interactive(self):
+        for item in self.items or []:
+            if item.is_interactive():
+                return True
+        return False
+
+    def get_global_interactive_form_url(self, formdef=None, ids=None):
+        token = get_publisher().token_class(size=32)
+        token.type = 'global-interactive-action'
+        token.context = {
+            'action_id': self.id,
+            'form_slug': formdef.slug,
+            'form_type': formdef.xml_root_node,
+            'form_ids': ids,
+            'return_url': get_request().get_path_query(),
+        }
+        token.store()
+        if get_request().is_in_backoffice():
+            return formdef.get_url(backoffice=True) + 'actions/%s/#' % token.id
+        else:
+            return '/actions/%s/#' % token.id
+
+    def handle_form(self, form, filled, user):
+        evo = Evolution()
+        url = super().handle_form(form, filled, user, evo)
+        if isinstance(url, str):
+            return url
+        filled.evolution.append(evo)
+        if evo.status:
+            filled.status = evo.status
+        filled.store()
+
     def get_admin_url(self):
         return '%sglobal-actions/%s/' % (self.parent.get_admin_url(), self.id)
 
@@ -1866,14 +1975,10 @@ class WorkflowStatus(SerieOfActionsMixin):
         return changed
 
     def get_action_form(self, filled, user, displayed_fields=None):
-        form = Form(enctype='multipart/form-data', use_tokens=False)
-        form.attrs['id'] = 'wf-actions'
-        for item in self.items:
-            if not item.check_auth(filled, user):
-                continue
-            if not item.check_condition(filled):
-                continue
-            item.fill_form(form, filled, user, displayed_fields=displayed_fields)
+        form = super().get_action_form(filled, user, displayed_fields=displayed_fields)
+        if form is None:
+            form = Form(enctype='multipart/form-data', use_tokens=False)
+            form.attrs['id'] = 'wf-actions'
 
         for action in filled.formdef.workflow.get_global_actions_for_user(filled, user):
             form.add_submit('button-action-%s' % action.id, action.name)
@@ -1888,26 +1993,6 @@ class WorkflowStatus(SerieOfActionsMixin):
         else:
             return None
 
-    def get_active_items(self, form, filled, user):
-        for item in self.items:
-            if hasattr(item, 'by'):
-                for role in item.by or []:
-                    if role == logged_users_role().id:
-                        break
-                    if role == '_submitter':
-                        if filled.is_submitter(user):
-                            break
-                        continue
-                    if user is None:
-                        continue
-                    if filled.get_function_roles(role).intersection(user.get_roles()):
-                        break
-                else:
-                    continue
-            if not item.check_condition(filled):
-                continue
-            yield item
-
     def get_admin_url(self):
         return self.parent.get_admin_url() + 'status/%s/' % self.id
 
@@ -1919,38 +2004,17 @@ class WorkflowStatus(SerieOfActionsMixin):
         # check for global actions
         for action in filled.formdef.workflow.get_global_actions_for_user(filled, user):
             if 'button-action-%s' % action.id in get_request().form:
+                if action.is_interactive():
+                    return action.get_global_interactive_form_url(formdef=filled.formdef, ids=[filled.id])
                 url = filled.perform_global_action(action.id, user, event_name='global-action-button')
                 if url:
                     return url
                 return
 
         evo = Evolution()
-        evo.time = time.localtime()
-        if user:
-            if filled.is_submitter(user):
-                evo.who = '_submitter'
-            else:
-                evo.who = user.id
-        if not filled.evolution:
-            filled.evolution = []
-
-        for item in self.get_active_items(form, filled, user):
-            next_url = item.submit_form(form, filled, user, evo)
-            if next_url is True:
-                break
-            if next_url:
-                if not form.has_errors():
-                    if evo.parts or evo.status or evo.comment or evo.status:
-                        # add evolution entry only if there's some content
-                        # within, i.e. do not register anything in the case of
-                        # a single edit action (where the evolution should be
-                        # appended only after successful edit).
-                        filled.evolution.append(evo)
-                        if evo.status:
-                            filled.status = evo.status
-                        filled.store()
-                return next_url
-
+        url = super().handle_form(form, filled, user, evo)
+        if isinstance(url, str):
+            return url
         if form.has_errors():
             return
 
@@ -2046,6 +2110,9 @@ class WorkflowStatus(SerieOfActionsMixin):
                 self.require_confirmation = action.require_confirmation
                 self.action = action
 
+            def is_interactive(self):
+                return False
+
         for action in self.items or []:
             if not isinstance(action, ChoiceWorkflowStatusItem):
                 continue
@@ -2159,6 +2226,9 @@ class WorkflowStatusItem(XmlSerialisable):
     directory_class = None
     support_substitution_variables = False
 
+    def __init__(self, parent=None):
+        self.parent = parent
+
     @classmethod
     def init(cls):
         pass
@@ -2232,6 +2302,9 @@ class WorkflowStatusItem(XmlSerialisable):
     def fill_form(self, form, formdata, user, **kwargs):
         pass
 
+    def is_interactive(self):
+        return False
+
     def evaluate_live_form(self, form, formdata, user):
         pass
 
-- 
2.37.2

