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

---
 tests/test_admin_pages.py     |  73 +++++++++-
 tests/test_workflow_import.py |  44 +++++-
 tests/test_workflows.py       | 251 +++++++++++++++++++++++++++++++++-
 wcs/admin/workflows.py        |   1 +
 wcs/carddef.py                |   2 +-
 wcs/wf/external_workflow.py   | 195 ++++++++++++++++++++++++++
 wcs/workflows.py              |  26 ++++
 7 files changed, 588 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..87381638 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,77 @@ 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_triggers(pub):
+    create_superuser(pub)
+
+    Workflow.wipe()
+    workflow = Workflow(name='foo')
+    workflow.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/%s/' % workflow.id)
+    resp = resp.click('add global action')
+    resp.forms[0]['name'] = 'Global Action with External workflow trigger'
+
+    resp = resp.forms[0].submit('submit')
+    resp = resp.follow()
+
+    # adding an external trigger
+    resp.forms[1]['type'] = 'External workflow'
+    resp = resp.forms[1].submit()
+    resp = resp.follow()
+
+    assert 'External workflow (not configured)' in resp.text
+    resp = resp.click(href='triggers/%s/' % Workflow.get(workflow.id).global_actions[0].triggers[1].id, index=0)
+    resp.form['name'] = ''
+    resp = resp.form.submit()
+    assert 'required field' in resp.text
+    resp.form['name'] = 'Test'
+    resp = resp.form.submit('submit').follow()
+    assert "External workflow (by name « Test »)" in resp.text
+    assert Workflow.get(workflow.id).global_actions[0].triggers[1].name == 'Test'
+
+
+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('external_workflow')
+    trigger.name = '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))
+    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.forms[0]['slug'] = 'formdef:%s' % formdef.url_name
+    resp = resp.forms[0].submit('submit').follow().follow()
+    assert 'External workflow (not completed)' in resp.text
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (workflow.id, st.id))
+    assert resp.forms[0]['target$type'].value == 'auto'
+    resp.forms[0]['target$type'] = 'expression'
+    resp = resp.forms[0].submit('submit')
+    assert "Expression must be filled" in resp
+    resp.forms[0]['target$type'] = 'auto'
+    resp.forms[0]['trigger_id'] = trigger.id
+    resp = resp.forms[0].submit('submit').follow().follow()
+    assert 'External workflow (trigger « test » on external)' in resp.text
+
+
 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..51202e11 100644
--- a/tests/test_workflow_import.py
+++ b/tests/test_workflow_import.py
@@ -14,7 +14,7 @@ from wcs.workflows import (
     Workflow, CommentableWorkflowStatusItem, WorkflowCriticalityLevel,
     WorkflowBackofficeFieldsFormDef, SendmailWorkflowStatusItem,
     SendSMSWorkflowStatusItem, WorkflowImportError, ChoiceWorkflowStatusItem,
-    JumpOnSubmitWorkflowStatusItem)
+    JumpOnSubmitWorkflowStatusItem, WorkflowGlobalActionExternalWorkflowTrigger)
 from wcs.wf.wscall import WebserviceCallStatusItem
 from wcs.wf.dispatch import DispatchWorkflowStatusItem
 from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
@@ -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,44 @@ 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('external_workflow')
+    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.target = {
+        'type': 'expression',
+        'expression': '{{ form_var_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..3a7a657f 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,250 @@ 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_object_expression(pub):
+    FormDef.wipe()
+    LoggedError.wipe()
+
+    external_formdef = FormDef()
+    external_formdef.name = 'External Form'
+    external_formdef.fields = [
+        StringField(id='0', label='string', varname='foo_string'),
+    ]
+    external_wf = Workflow(name='External Workflow')
+    action = external_wf.add_global_action('Delete', 'delete')
+    action.append_item('remove')
+    trigger = action.append_trigger('external_workflow')
+    trigger.name = 'delete'
+    external_wf.store()
+
+    external_formdef.workflow = external_wf
+    external_formdef.store()
+
+    wf = Workflow(name='External call')
+    st1 = wf.add_status('Call external workflow')
+    external_wf_action = ExternalWorkflowGlobalAction()
+    external_wf_action.slug = 'formdef:%s' % external_formdef.url_name
+    external_wf_action.trigger_id = 'unknown'
+    external_wf_action.target  = {
+        'type': 'expression',
+        'expression': '{{ form_var_target_id }}'
+    }
+    external_wf_action.parent = st1
+    st1.items.append(external_wf_action)
+    wf.store()
+
+    formdef = FormDef()
+    formdef.name = 'External call form'
+    formdef.fields = [
+        StringField(id='0', label='string', varname='target_id'),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    # unknown trigger
+    formdata = formdef.data_class()()
+    formdata.data = {'0': 'whatever'}
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert LoggedError.count() == 1
+    logged_error = LoggedError.select()[0]
+    assert logged_error.summary == 'Could not find trigger "unknown"'
+
+    # unknown object id
+    LoggedError.wipe()
+
+    new_wf = Workflow(name='New external call')
+    st1 = new_wf.add_status('Call external workflow')
+    external_wf_action.trigger_id = trigger.id
+    st1.items.append(external_wf_action)
+    new_wf.store()
+
+    formdef.workflow = new_wf
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': '42'}
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert LoggedError.count() == 1
+    logged_error = LoggedError.select()[0]
+    assert logged_error.summary == 'Could not find object "External Form" by id 42'
+    assert logged_error.exception_class == 'KeyError'
+
+    # no errors
+    LoggedError.wipe()
+    external_formdata = external_formdef.data_class()()
+    external_formdata.data = {'0': 'test'}
+    external_formdata.store()
+    assert external_formdef.data_class().count() == 1
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': external_formdata.id}
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert LoggedError.count() == 0
+    assert external_formdef.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('external_workflow')
+    trigger.name = '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 = trigger.id
+    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('external_workflow')
+    trigger.name = '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 = trigger.id
+    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/admin/workflows.py b/wcs/admin/workflows.py
index a3c573ed..47f5eac6 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -1301,6 +1301,7 @@ class GlobalActionPage(WorkflowStatusPage):
             ('timeout', _('Automatic')),
             ('manual', _('Manual')),
             ('webservice', _('Webservice')),
+            ('external_workflow', _('External workflow')),
         ]
         form.add(SingleSelectWidget, 'type', title=_('Type'),
                 required=True, options=available_triggers)
diff --git a/wcs/carddef.py b/wcs/carddef.py
index e2df078b..62166ca4 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
@@ -81,6 +80,7 @@ class CardDef(FormDef):
     @classmethod
     def get_default_workflow(cls):
         from wcs.workflows import EditableWorkflowStatusItem, ChoiceWorkflowStatusItem
+        from wcs.workflows import 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..60622562
--- /dev/null
+++ b/wcs/wf/external_workflow.py
@@ -0,0 +1,195 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2016  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,
+                             CompositeWidget)
+
+from wcs.logged_errors import LoggedError
+from wcs.workflows import WorkflowStatusItem, perform_items, register_item_class
+from wcs.workflows import WorkflowGlobalActionExternalWorkflowTrigger, Workflow
+from wcs.wf.create_formdata import LinkedFormdataEvolutionPart
+from wcs.carddef import CardDef
+from wcs.formdef import FormDef
+
+
+class TargetWidget(CompositeWidget):
+    target_types = [
+        ('auto', _('Automatic'), 'auto'),
+        ('expression', _('Expression'), 'expression')
+    ]
+
+    def __init__(self, name, value={}, **kwargs):
+        super(TargetWidget, self).__init__(name, value, **kwargs)
+        self.add(SingleSelectWidget, 'type',
+                 value=self.value.get('type') or 'auto',
+                 options=self.target_types,
+                 render_br=False,
+                 attrs={'data-dynamic-display-parent': 'true'}
+        )
+        self.add(ComputedExpressionWidget, 'expression',
+                 value=value['expression'],
+                 extra_css_class='grid-3-4',
+                 render_br=False,
+                 attrs={
+                     'data-dynamic-display-child-of': '%s$type' % name,
+                     'data-dynamic-display-value': 'expression'
+                 }
+        )
+        # put widgets in grid
+        self.widgets[0].extra_css_class = 'grid-1-4'
+        self.widgets[1].extra_css_class = 'grid-3-4'
+
+    def _parse(self, request):
+        super(TargetWidget, self)._parse(request)
+        if self.get('type') == 'expression' and not self.get('expression'):
+            self.error = _('Expression must be filled')
+            return
+        self.value = {
+            'type': self.get('type'),
+            'expression': self.get('expression')
+        }
+
+
+class ExternalWorkflowGlobalAction(WorkflowStatusItem):
+
+    description = _('External workflow')
+    key = 'external_workflow_global_action'
+    category = 'formdata-action'
+
+
+
+    slug = None
+    target = {'type': 'auto', 'expression': ''}
+    trigger_id = None
+
+    def get_workflow_external_triggers(self, workflow):
+        triggers = []
+        for action in workflow.global_actions or []:
+            for trigger in action.triggers or []:
+                if isinstance(trigger, WorkflowGlobalActionExternalWorkflowTrigger):
+                    triggers.append(trigger)
+        return triggers
+
+    def get_object_def(self):
+        object_type, slug = self.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):
+        for trigger in self.get_workflow_external_triggers(workflow):
+            if trigger.id == self.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 self.get_workflow_external_triggers(wf):
+                    for objectdef in wf.formdefs() + wf.carddefs():
+                        object_slug = '%s:%s' % (objectdef.__class__.__name__.lower(),
+                                                 objectdef.url_name)
+                        objects += [(object_slug, objectdef.name, object_slug)]
+            objects.sort(key=lambda x: x[1])
+            form.add(SingleSelectWidget, '%sslug' % prefix,
+                     title=_('Object type'),
+                     value=self.slug,
+                     options=objects)
+        if 'target' in parameters and self.slug:
+            form.add(TargetWidget, '%starget' % prefix,
+                     title=_('Target object'),
+                     value=self.target
+            )
+
+        if 'trigger_id' in parameters and self.slug:
+            triggers_names = [(None, '---', '')]
+            object_def = self.get_object_def()
+            for trigger in self.get_workflow_external_triggers(object_def.workflow):
+                if trigger.name:
+                    triggers_names += [(trigger.id, trigger.name, trigger.id)]
+            form.add(SingleSelectWidget, '%strigger_id' % prefix,
+                     title=_('Trigger'),
+                     value=self.trigger_id,
+                     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)
+            if trigger and objectdef:
+                return _('trigger « %s » on %s' % (trigger.name, objectdef.name))
+        return _('not completed')
+
+    def iter_target_datas(self, formdata, objectdef):
+        data_ids = []
+        if self.target['type'] == 'expression':
+            data_id = self.compute(self.target['expression'], formdata=formdata)
+            data_ids.append(data_id)
+            error_msg = 'Could not find object "%s" by id %s' % (objectdef.name, data_id)
+        else:
+            # search linked objects in data sources
+            for field in formdata.get_formdef().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',  'target', '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 35b07ac5..8f4656be 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
@@ -1307,6 +1311,26 @@ class WorkflowGlobalActionWebserviceTrigger(WorkflowGlobalActionManualTrigger):
         return [('hooks', WorkflowGlobalActionWebserviceHooksDirectory(formdata))]
 
 
+class WorkflowGlobalActionExternalWorkflowTrigger(WorkflowGlobalActionManualTrigger):
+    key = 'external_workflow'
+    name = None
+
+    def get_parameters(self):
+        return ('name',)
+
+    def render_as_line(self):
+        if self.name:
+            return _('External workflow (by name « %s »)') % self.name
+        else:
+            return _('External workflow (not configured)')
+
+    def form(self, workflow):
+        form = Form(enctype='multipart/form-data')
+        form.add(StringWidget, 'name', title=_('Name'),
+                 required=True, value=self.name)
+        return form
+
+
 class WorkflowGlobalAction(object):
     id = None
     name = None
@@ -1336,6 +1360,7 @@ class WorkflowGlobalAction(object):
             'manual': WorkflowGlobalActionManualTrigger,
             'timeout': WorkflowGlobalActionTimeoutTrigger,
             'webservice': WorkflowGlobalActionWebserviceTrigger,
+            'external_workflow': WorkflowGlobalActionExternalWorkflowTrigger,
         }
         o = trigger_types.get(type)()
         if not self.triggers:
@@ -2927,5 +2952,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.0

