From 561a0d4ea4b88c636c87389acb3aba3393e3f80f Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Fri, 28 Feb 2020 11:29:57 +0100
Subject: [PATCH 3/3] wf: add external workflow action (#40204)

---
 tests/test_admin_pages.py     |  48 +++++++++-
 tests/test_workflow_import.py |  37 ++++++++
 tests/test_workflows.py       | 160 +++++++++++++++++++++++++++++++++-
 wcs/carddef.py                |   4 +-
 wcs/wf/external_workflow.py   | 158 +++++++++++++++++++++++++++++++++
 wcs/workflows.py              |   5 ++
 6 files changed, 408 insertions(+), 4 deletions(-)
 create mode 100644 wcs/wf/external_workflow.py

diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py
index e14f8902..1c7dbd6e 100644
--- a/tests/test_admin_pages.py
+++ b/tests/test_admin_pages.py
@@ -1210,7 +1210,7 @@ def test_form_delete_field_existing_data(pub):
     resp = resp.forms[0].submit()
     resp = resp.follow()
     assert len(FormDef.get(1).fields) == 0
- 
+
 def test_form_duplicate_field(pub):
     user = create_superuser(pub)
     create_role()
@@ -3502,6 +3502,52 @@ def test_workflows_global_actions_timeout_triggers(pub):
     assert Workflow.get(workflow.id).global_actions[0].triggers[0].timeout == '-2'
 
 
+def test_workflows_global_actions_external_workflow_action(pub):
+    create_superuser(pub)
+    Workflow.wipe()
+
+    wf = Workflow(name='external')
+    action = wf.add_global_action('Global action')
+    trigger = action.append_trigger('webservice')
+    trigger.identifier = 'test'
+    item = action.append_item('remove')
+    wf.store()
+
+    formdef = FormDef()
+    formdef.name = 'external'
+    formdef.workflow = wf
+    formdef.store()
+    workflow = Workflow(name='foo')
+    st = workflow.add_status('New')
+    workflow.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/%s/status/%s/' % (workflow.id, st.id))
+    assert 'External workflow' not in [o[0] for o in resp.forms[0]['action-formdata-action'].options]
+
+    # activate option
+    if not pub.site_options.has_section('options'):
+        pub.site_options.add_section('options')
+    pub.site_options.set('options', 'external-workflow', 'true')
+    with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
+        pub.site_options.write(fd)
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/' % (workflow.id, st.id))
+    resp.forms[0]['action-formdata-action'] = 'External workflow'
+    resp = resp.forms[0].submit().follow()
+    assert 'External workflow (not completed)' in resp.text
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (workflow.id, st.id))
+    resp = resp.forms[0].submit('submit')
+    assert "required field" in resp.text
+    resp.forms[0]['slug'] = 'formdef:%s' % formdef.url_name
+    resp = resp.forms[0].submit('submit')
+    assert "required field" in resp.text
+    resp = resp.forms[0].submit('submit')
+    resp.forms[0]['trigger_id'] = 'action:%s' % trigger.identifier
+    resp = resp.forms[0].submit('submit').follow().follow()
+
+
 def test_workflows_criticality_levels(pub):
     create_superuser(pub)
     create_role()
diff --git a/tests/test_workflow_import.py b/tests/test_workflow_import.py
index b2d98190..4c6b1e03 100644
--- a/tests/test_workflow_import.py
+++ b/tests/test_workflow_import.py
@@ -22,6 +22,7 @@ from wcs.wf.profile import UpdateUserProfileStatusItem
 from wcs.wf.backoffice_fields import SetBackofficeFieldsWorkflowStatusItem
 from wcs.wf.redirect_to_url import RedirectToUrlWorkflowStatusItem
 from wcs.wf.create_formdata import CreateFormdataWorkflowStatusItem, Mapping
+from wcs.wf.external_workflow import ExternalWorkflowGlobalAction
 from wcs.roles import Role
 from wcs.fields import StringField, FileField
 
@@ -753,3 +754,39 @@ def test_create_formdata(pub):
     wf.store()
 
     assert_import_export_works(wf, include_id=True)
+
+
+def test_external_workflow(pub):
+    target_wf = Workflow(name='External global action')
+    action = target_wf.add_global_action('Delete', 'delete')
+    trigger = action.append_trigger('webservice')
+    trigger.trigger_id = 'Cleanup'
+    target_wf.store()
+
+    target_formdef = FormDef()
+    target_formdef.name = 'target form'
+    target_formdef.workflow = target_wf
+    target_formdef.store()
+
+    wf = Workflow(name='External workflow call')
+    st1 = wf.add_status('New')
+    st2 = wf.add_status('Call external workflow')
+
+    jump = ChoiceWorkflowStatusItem()
+    jump.id = '_external'
+    jump.label = 'Cleanup'
+    jump.by = ['_submitter']
+    jump.status = st2.id
+    jump.parent = st1
+    st1.items.append(jump)
+
+    external_workflow = ExternalWorkflowGlobalAction()
+    external_workflow.id = '_external_workflow'
+    external_workflow.slug = 'formdef:%s' % target_formdef.url_name
+    external_workflow.event = trigger.id
+
+    external_workflow.parent = st2
+    st2.items.append(external_workflow)
+
+    wf.store()
+    assert_import_export_works(wf, include_id=True)
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index 8db74524..c2a0ac8a 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -34,7 +34,8 @@ from wcs.workflows import (Workflow, WorkflowStatusItem,
         CommentableWorkflowStatusItem, ChoiceWorkflowStatusItem,
         DisplayMessageWorkflowStatusItem,
         AbortActionException, WorkflowCriticalityLevel,
-        AttachmentEvolutionPart, WorkflowBackofficeFieldsFormDef)
+        AttachmentEvolutionPart, WorkflowBackofficeFieldsFormDef,
+        perform_items)
 from wcs.wf.aggregation_email import (AggregationEmailWorkflowStatusItem,
         AggregationEmail, send_aggregation_emails)
 from wcs.wf.anonymise import AnonymiseWorkflowStatusItem
@@ -55,6 +56,7 @@ from wcs.wf.redirect_to_url import RedirectToUrlWorkflowStatusItem
 from wcs.wf.notification import SendNotificationWorkflowStatusItem
 from wcs.wf.create_formdata import CreateFormdataWorkflowStatusItem, Mapping
 from wcs.wf.create_carddata import CreateCarddataWorkflowStatusItem
+from wcs.wf.external_workflow import ExternalWorkflowGlobalAction
 
 
 from utilities import (create_temporary_pub, MockSubstitutionVariables,
@@ -4632,3 +4634,159 @@ def test_create_carddata(pub):
     assert carddef.data_class().count() == 0
     formdata.perform_workflow()
     assert carddef.data_class().count() == 0
+
+
+def test_call_external_workflow_with_evolution_linked_object(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+    LoggedError.wipe()
+
+    external_wf = Workflow(name='External Workflow')
+    st1 = external_wf.add_status(name='New')
+    action = external_wf.add_global_action('Delete', 'delete')
+    action.append_item('remove')
+    trigger = action.append_trigger('webservice')
+    trigger.identifier = 'delete'
+    external_wf.store()
+
+    external_formdef = FormDef()
+    external_formdef.name = 'External Form'
+    external_formdef.fields = [
+        StringField(id='0', label='string', varname='form_string'),
+    ]
+    external_formdef.workflow = external_wf
+    external_formdef.store()
+
+    external_carddef = CardDef()
+    external_carddef.name = 'External Card'
+    external_carddef.fields = [
+        StringField(id='0', label='string', varname='card_string'),
+    ]
+    external_carddef.workflow = external_wf
+    external_carddef.store()
+
+    wf = Workflow(name='External actions')
+    st1 = wf.add_status('Create external formdata')
+    create_formdata = CreateFormdataWorkflowStatusItem()
+    create_formdata.label = 'create linked form'
+    create_formdata.formdef_slug = external_formdef.url_name
+    create_formdata.varname = 'created_form'
+    create_formdata.id = '_create_form'
+    mappings = [
+        Mapping(field_id='0', expression='{{ form_var_string }}')
+    ]
+    create_formdata.mappings = mappings
+    create_formdata.parent = st1
+
+    create_carddata = CreateCarddataWorkflowStatusItem()
+    create_carddata.label = 'create linked card'
+    create_carddata.formdef_slug = external_carddef.url_name
+    create_carddata.varname = 'created_card'
+    create_carddata.id = '_create_card'
+    create_carddata.mappings = mappings
+    create_carddata.parent = st1
+
+    st1.items.append(create_formdata)
+    st1.items.append(create_carddata)
+
+    global_action = wf.add_global_action('Delete external linked object', 'delete')
+    action = global_action.append_item('external_workflow_global_action')
+    action.slug = 'formdef:%s' % external_formdef.url_name
+    action.trigger_id = 'action:%s' % trigger.identifier
+    wf.store()
+
+    formdef = FormDef()
+    formdef.name = 'External action form'
+    formdef.fields = [
+        StringField(id='0', label='string', varname='string'),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    assert external_formdef.data_class().count() == 0
+    assert external_carddef.data_class().count() == 0
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': 'test form'}
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert external_formdef.data_class().count() == 1
+    assert external_carddef.data_class().count() == 1
+    perform_items([action], formdata)
+    assert LoggedError.count() == 0
+    assert external_formdef.data_class().count() == 0
+    assert external_carddef.data_class().count() == 1
+
+    perform_items([action], formdata)
+    assert LoggedError.count() == 1
+    logged_error = LoggedError.select()[0]
+    assert logged_error.summary == 'Could not find linked "External Form" object'
+    assert logged_error.exception_class == 'KeyError'
+
+
+def test_call_external_workflow_with_data_sourced_object(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+    LoggedError.wipe()
+
+    carddef_wf = Workflow(name='Carddef Workflow')
+    st1 = carddef_wf.add_status(name='New')
+    action = carddef_wf.add_global_action('Delete', 'delete')
+    action.append_item('remove')
+    trigger = action.append_trigger('webservice')
+    trigger.identifier = 'delete'
+    carddef_wf.store()
+
+    carddef = CardDef()
+    carddef.name = 'Data'
+    carddef.fields = [
+        StringField(id='0', label='string', varname='card_string'),
+    ]
+    carddef.digest_template = '{{ form_var_card_string }}'
+    carddef.workflow = carddef_wf
+    carddef.store()
+
+    carddata = carddef.data_class()()
+    carddata.data = {'0': 'Text'}
+    carddata.store()
+
+    wf = Workflow(name='External actions')
+    st1 = wf.add_status('Action')
+
+    global_action = wf.add_global_action('Delete external linked object', 'delete')
+    action = global_action.append_item('external_workflow_global_action')
+    action.slug = 'carddef:%s' % carddef.url_name
+    action.trigger_id = 'action:%s' % trigger.identifier
+    wf.store()
+
+    datasource = {'type': 'carddef:%s' % carddef.url_name}
+    formdef = FormDef()
+    formdef.name = 'External action form'
+    formdef.fields = [
+        ItemField(id='0', label='Card',
+            type='item', varname='card',
+            data_source=datasource)
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    assert LoggedError.count() == 0
+    assert carddef.data_class().count() == 1
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': '1'}
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    perform_items([action], formdata)
+    assert LoggedError.count() == 0
+    assert carddef.data_class().count() == 0
+
+    perform_items([action], formdata)
+    assert LoggedError.count() == 1
+    logged_error = LoggedError.select()[0]
+    assert logged_error.summary == 'Could not find linked "Data" object'
+    assert logged_error.exception_class == 'KeyError'
diff --git a/wcs/carddef.py b/wcs/carddef.py
index e2df078b..a5e8c9d3 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -22,7 +22,6 @@ from .qommon import _
 
 from wcs.carddata import CardData
 from wcs.formdef import FormDef
-from wcs.workflows import Workflow
 
 if not hasattr(types, 'ClassType'):
     types.ClassType = type
@@ -80,7 +79,8 @@ class CardDef(FormDef):
 
     @classmethod
     def get_default_workflow(cls):
-        from wcs.workflows import EditableWorkflowStatusItem, ChoiceWorkflowStatusItem
+        from wcs.workflows import (EditableWorkflowStatusItem,
+                                 ChoiceWorkflowStatusItem, Workflow)
         from wcs.wf.remove import RemoveWorkflowStatusItem
         workflow = Workflow(name=_('Default (cards)'))
         workflow.id = '_carddef_default'
diff --git a/wcs/wf/external_workflow.py b/wcs/wf/external_workflow.py
new file mode 100644
index 00000000..80839549
--- /dev/null
+++ b/wcs/wf/external_workflow.py
@@ -0,0 +1,158 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+import time
+import xml.etree.ElementTree as ET
+
+from quixote import get_request, get_session, get_publisher
+from quixote.html import htmltext
+
+from django.utils.functional import cached_property
+
+from wcs.qommon import _
+from wcs.qommon.form import SingleSelectWidget, ComputedExpressionWidget
+
+from wcs.logged_errors import LoggedError
+from wcs.workflows import WorkflowStatusItem, perform_items, register_item_class
+from wcs.workflows import WorkflowGlobalActionWebserviceTrigger, Workflow
+from wcs.wf.create_formdata import LinkedFormdataEvolutionPart
+from wcs.carddef import CardDef
+from wcs.formdef import FormDef
+
+
+class ExternalWorkflowGlobalAction(WorkflowStatusItem):
+
+    description = _('External workflow')
+    key = 'external_workflow_global_action'
+    category = 'formdata-action'
+
+    slug = None
+    trigger_id = None
+
+    @classmethod
+    def is_available(cls, workflow=None):
+        return get_publisher().has_site_option('external-workflow')
+
+    def get_workflow_webservice_triggers(self, workflow):
+        triggers = []
+        for action in workflow.global_actions or []:
+            for trigger in action.triggers or []:
+                if isinstance(trigger, WorkflowGlobalActionWebserviceTrigger):
+                    yield trigger
+
+    def get_object_def(self, object_slug=None):
+        slug = object_slug or self.slug
+        object_type, slug = slug.split(':')
+        if object_type == 'formdef':
+            object_class = FormDef
+        elif object_type == 'carddef':
+            object_class = CardDef
+        try:
+            return object_class.get_by_urlname(slug)
+        except Exception as e:
+            pass
+
+    def get_trigger(self, workflow):
+        try:
+            trigger_type, trigger_id = self.trigger_id.split(':', 1)
+        except ValueError:
+            return
+        for trigger in self.get_workflow_webservice_triggers(workflow):
+            if trigger.identifier == trigger_id:
+                return trigger
+
+    def add_parameters_widgets(self, form, parameters, prefix='', formdef=None):
+        super(ExternalWorkflowGlobalAction, self).add_parameters_widgets(
+            form, parameters, prefix=prefix, formdef=formdef)
+
+        if 'slug' in parameters:
+            objects = [(None, '---', '')]
+            for wf in Workflow.select():
+                if any(self.get_workflow_webservice_triggers(wf)):
+                    for objectdef in wf.formdefs(lightweight=True) + wf.carddefs(lightweight=True):
+                        object_slug = '%s:%s' % (objectdef.__class__.__name__.lower(),
+                                                 objectdef.url_name)
+                        objects += [(object_slug, objectdef.name, object_slug)]
+            if len(objects) == 1:
+                form.add_global_errors((_('No workflow with external triggerable global action '),))
+                return
+            objects.sort(key=lambda x: x[1])
+            form.add(SingleSelectWidget, '%sslug' % prefix,
+                     title=_('Form/Card'),
+                     value=self.slug,
+                     required=True,
+                     options=objects)
+
+        if 'trigger_id' in parameters and form.get('%sslug' % prefix):
+            object_def = self.get_object_def(form.get('%sslug' % prefix))
+            triggers_names = [(None, '---', '')]
+            for trigger in self.get_workflow_webservice_triggers(object_def.workflow):
+                if trigger.identifier:
+                    trigger_id = 'action:%s' % trigger.identifier
+                    triggers_names += [(trigger_id, trigger.parent.name, trigger_id)
+                    ]
+            form.add(SingleSelectWidget, '%strigger_id' % prefix,
+                     title=_('Action'),
+                     value=self.trigger_id,
+                     required=True,
+                     options=triggers_names)
+
+    def get_line_details(self):
+        if self.slug and self.trigger_id:
+            objectdef = self.get_object_def()
+            trigger = self.get_trigger(objectdef.workflow)
+            return _('action "%s" on %s' % (trigger.parent.name, objectdef.name))
+        return _('not completed')
+
+    def iter_target_datas(self, formdata, objectdef):
+        data_ids = []
+        # search linked objects in data sources
+        for field in formdata.get_formdef().get_all_fields():
+            if field.data_source and field.data_source['type'] == self.slug:
+                data_ids.append(formdata.data.get(field.id))
+
+        # search in evolution
+        for part in formdata.iter_evolution_parts():
+            if isinstance(part, LinkedFormdataEvolutionPart) and part.formdef_class == objectdef.__class__:
+                data_ids.append(part.formdata_id)
+        error_msg = 'Could not find linked "%s" object' % objectdef.name
+
+        for target_id in data_ids:
+            try:
+                yield objectdef.data_class().get(target_id)
+            except KeyError as e:
+                # use custom error message depending on target type
+                LoggedError.record(error_msg, formdata=formdata, exception=e)
+
+    def get_parameters(self):
+        return ('slug',  'trigger_id', 'condition')
+
+    def perform(self, formdata):
+        objectdef = self.get_object_def()
+        if not objectdef:
+            return
+
+        trigger = self.get_trigger(objectdef.workflow)
+        if not trigger:
+            LoggedError.record('Could not find trigger "%s"' % self.trigger_id,
+                               formdata=formdata)
+            return
+
+        for target_data in self.iter_target_datas(formdata, objectdef):
+            perform_items(trigger.parent.items, target_data)
+
+
+register_item_class(ExternalWorkflowGlobalAction)
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 6323e61d..40cd2f43 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -44,6 +44,7 @@ from .conditions import Condition
 from .roles import Role, logged_users_role, get_user_roles
 from .fields import FileField
 from .formdef import FormDef
+from .carddef import CardDef
 from .formdata import Evolution
 
 if not __name__.startswith('wcs.') and not __name__ == "__main__":
@@ -834,6 +835,9 @@ class Workflow(StorableObject):
     def formdefs(self, **kwargs):
         return list(FormDef.select(lambda x: x.workflow_id == self.id, **kwargs))
 
+    def carddefs(self, **kwargs):
+        return list(CardDef.select(lambda x: x.workflow_id == self.id, **kwargs))
+
 
 class XmlSerialisable(object):
     node_name = None
@@ -2927,5 +2931,6 @@ def load_extra():
     from .wf import notification
     from .wf import create_formdata
     from .wf import create_carddata
+    from .wf import external_workflow
 
 from .wf.export_to_model import ExportToModel
-- 
2.26.1

