From 6799b3565902a0cf9f10588f0317fe6b65102697 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           |  72 ++++++++++-
 tests/test_workflow_import.py       |  41 +++++-
 tests/test_workflows.py             | 191 ++++++++++++++++++++++++++-
 wcs/admin/workflows.py              |   1 +
 wcs/carddef.py                      |   2 +-
 wcs/qommon/static/css/dc2/admin.css |   8 ++
 wcs/wf/external_workflow.py         | 193 ++++++++++++++++++++++++++++
 wcs/workflows.py                    |  26 ++++
 8 files changed, 530 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..3b54ac0a 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,76 @@ 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['event_name'] = ''
+    resp = resp.form.submit()
+    assert 'required field' in resp.text
+    resp.form['event_name'] = 'Test'
+    resp = resp.form.submit('submit').follow()
+    assert Workflow.get(workflow.id).global_actions[0].triggers[1].event_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.event_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_object$auto'].checked
+    resp.forms[0]['target_object$auto'].checked = False
+    resp = resp.forms[0].submit('submit')
+    assert "Auto or object identifier should be filled" in resp
+    resp.forms[0]['target_object$auto'].checked = True
+    resp.forms[0]['event'] = trigger.id
+    resp = resp.forms[0].submit('submit').follow().follow()
+    assert 'External workflow (event ' 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..c867b475 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, TargetObject
 from wcs.roles import Role
 from wcs.fields import StringField, FileField
 
@@ -753,3 +754,41 @@ 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.event_name = '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_object = TargetObject()
+    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..647bbaac 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, TargetObject
 
 
 from utilities import (create_temporary_pub, MockSubstitutionVariables,
@@ -4632,3 +4634,190 @@ 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_by_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.event_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.event = 'unknown'
+    external_wf_action.target_object = TargetObject(False, '{{ 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.event = 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 FormDef by id 42'
+    assert logged_error.exception_class == 'KeyError'
+    assert logged_error.expression == '{{ form_var_target_id }}'
+
+    # 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_by_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.event_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.event = trigger.id
+    action.target_object = TargetObject()
+    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()
+
+    perform_items([action], formdata)
+    assert LoggedError.count() == 1
+    logged_error = LoggedError.select()[0]
+    assert logged_error.summary == 'No linked FormDef object'
+
+    LoggedError.wipe()
+    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 == 'Linked object "External Form #1-1" does not exist'
+    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/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css
index e532f090..4e10f285 100644
--- a/wcs/qommon/static/css/dc2/admin.css
+++ b/wcs/qommon/static/css/dc2/admin.css
@@ -1878,3 +1878,11 @@ table#listing tr.checked td {
 	width: 40%;
 	min-width: 10em;
 }
+
+.ObjectWidget > .content {
+	padding-left: 2em;
+}
+
+.ObjectWidget br.content {
+	display: none;
+}
diff --git a/wcs/wf/external_workflow.py b/wcs/wf/external_workflow.py
new file mode 100644
index 00000000..3375e95a
--- /dev/null
+++ b/wcs/wf/external_workflow.py
@@ -0,0 +1,193 @@
+# 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, CheckboxWidget)
+
+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 TargetObject(object):
+
+    def __init__(self, auto=True, expression=None):
+        self.auto = auto
+        self.expression = expression
+
+
+class ObjectWidget(CompositeWidget):
+    def __init__(self, name, value=None,  **kwargs):
+        value = value or TargetObject(True, '')
+        super(ObjectWidget, self).__init__(name, value, **kwargs)
+
+        self.add(CheckboxWidget,
+                 name='auto',
+                 title=_('Auto'),
+                 hint=_('Reference linked object to current data'),
+                 value=value.auto)
+        self.add(ComputedExpressionWidget,
+                 name='expression',
+                 title=_('Or identifier'),
+                 value=value.expression,
+                 hint=_('Object identifier expression.'))
+
+    def _parse(self, request):
+        super(ObjectWidget, self)._parse(request)
+        if self.get('auto') is False and not self.get('expression'):
+            self.error = _('Auto or object identifier should be filled')
+            return
+        if self.get('auto') is not None:
+            self.value = TargetObject(self.get('auto'), self.get('expression') or '')
+        else:
+            self.value = None
+
+
+class ExternalWorkflowGlobalAction(WorkflowStatusItem):
+
+    description = _('External workflow')
+    key = 'external_workflow_global_action'
+    category = 'formdata-action'
+
+    slug = None
+    target_object = None
+    event = 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.event:
+                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, 'slug',
+                     title=_('Object type'),
+                     value=self.slug,
+                     options=objects)
+
+        if 'target_object' in parameters and self.slug:
+            form.add(ObjectWidget, '%starget_object' % prefix,
+                     title=_('Target object'), value=self.target_object)
+
+        if 'event' in parameters and self.slug:
+            events = [(None, '---', '')]
+            obj_def = self.get_object_def()
+            for trigger in self.get_workflow_external_triggers(obj_def.workflow):
+                events += [(trigger.id, trigger.event_name, trigger.id)]
+            form.add(SingleSelectWidget, 'event',
+                     title=_('Event'),
+                     value=self.event,
+                     options=events)
+
+    def get_line_details(self):
+        if self.slug and self.event:
+            objectdef = self.get_object_def()
+            trigger = self.get_trigger(objectdef.workflow)
+            if trigger and objectdef:
+                return _('event "%s" on %s' % (trigger.event_name, objectdef.name))
+        return _('not completed')
+
+    def get_parameters(self):
+        return ('slug', 'target_object', 'event')
+
+    def target_object__export_to_xml(self, parent, charset, include_id=False):
+        container = ET.SubElement(parent, 'target_object')
+        container.attrib['auto'] = self.target_object.auto
+        container.text = self.target_object.expression
+
+    def target_object__init_with_xml(self, container, charset, include_id=False):
+        self.target_object = TargetObject(container.attrib['auto'], container.text)
+
+    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.event,
+                               formdata=formdata)
+            return
+
+        if not self.target_object.auto:
+            identifier = self.compute(self.target_object.expression, formdata=formdata)
+            try:
+                target_data = objectdef.data_class().get(identifier)
+            except KeyError as e:
+                LoggedError.record('Could not find object %s by id %s' % (objectdef.__class__.__name__,
+                                                                          identifier),
+                                   expression=self.target_object.expression,
+                                   formdata=formdata, exception=e)
+                return
+        else:
+            for part in formdata.iter_evolution_parts():
+                if isinstance(part, LinkedFormdataEvolutionPart) and part.formdef_class == objectdef.__class__:
+                    target_data = part._formdata
+                    break
+            else:
+                LoggedError.record('No linked %s object' % (objectdef.__class__.__name__),
+                                   formdata=formdata)
+                return
+        try:
+            perform_items(trigger.parent.items, target_data)
+        except KeyError as e:
+            LoggedError.record('Linked object "%s" does not exist' % target_data.get_display_name(),
+                               formdata=formdata, exception=e)
+
+
+register_item_class(ExternalWorkflowGlobalAction)
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 35b07ac5..1f3ad11a 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'
+    event_name = None
+
+    def get_parameters(self):
+        return ('event_name',)
+
+    def render_as_line(self):
+        if self.event_name:
+            return _('External workflow (by event "%s")') % self.event_name
+        else:
+            return _('External workflow (not configured)')
+
+    def form(self, workflow):
+        form = Form(enctype='multipart/form-data')
+        form.add(StringWidget, 'event_name', title=_('Event name'),
+                 required=True, value=self.event_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

