workflows: add possibility to trigger global actions with a webservice (#32184)

This commit is contained in:
Frédéric Péters 2019-04-11 10:48:03 +02:00
parent 2513ad6665
commit c57cc62ac9
7 changed files with 228 additions and 5 deletions

View File

@ -64,6 +64,23 @@ workflow du formulaire.
<output>{"url": null, "err": 0}</output>
</screen>
<p>
Il est également possible de définir des déclencheurs au niveau des actions
globales du workflow, ils pourront alors être appelés quel que soit le statut
de la demande.
</p>
<p>
Un tel appel, avec un déclencheur global ici intitulé <code>urgent</code> se
ferait ainsi :
</p>
<screen>
<output style="prompt">$ </output><input>curl -H "Accept: application/json" -X POST \
https://www.example.net/api/forms/newsletter/14/hooks/urgent<var>?signature…</var></input>
<output>{"err": 0}</output>
</screen>
</section>
</page>

View File

@ -28,6 +28,7 @@ from wcs.categories import Category
from wcs.data_sources import NamedDataSource
from wcs.workflows import Workflow, EditableWorkflowStatusItem, WorkflowBackofficeFieldsFormDef
from wcs.wf.jump import JumpWorkflowStatusItem
from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
from wcs import fields, qommon
from wcs.api_utils import sign_url, get_secret_and_orig, is_url_signed, DEFAULT_DURATION
from wcs.qommon.errors import AccessForbiddenError
@ -2277,6 +2278,75 @@ def test_workflow_trigger_jump_once(pub, local_user):
assert resp.json == {'err': 0, 'url': None}
assert formdef.data_class().get(formdata.id).status == 'wf-st3'
def test_workflow_global_webservice_trigger(pub, local_user):
workflow = Workflow(name='test')
st1 = workflow.add_status('Status1', 'st1')
ac1 = workflow.add_global_action('Action', 'ac1')
trigger = ac1.append_trigger('webservice')
trigger.identifier = 'plop'
add_to_journal = RegisterCommenterWorkflowStatusItem()
add_to_journal.id = '_add_to_journal'
add_to_journal.comment = 'HELLO WORLD'
ac1.items.append(add_to_journal)
add_to_journal.parent = ac1
workflow.store()
FormDef.wipe()
formdef = FormDef()
formdef.name = 'test'
formdef.fields = []
formdef.workflow_id = workflow.id
formdef.store()
formdef.data_class().wipe()
formdata = formdef.data_class()()
formdata.just_created()
formdata.store()
assert formdef.data_class().get(formdata.id).status == 'wf-st1'
# call to undefined hook
resp = get_app(pub).post(sign_uri(formdata.get_url() + 'hooks/XXX/'), status=404)
resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/XXX/'), status=404)
# anonymous call
resp = get_app(pub).post(formdata.get_url() + 'hooks/plop/', status=200)
assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD'
add_to_journal.comment = 'HELLO WORLD 2'
workflow.store()
resp = get_app(pub).post(formdata.get_api_url() + 'hooks/plop/', status=200)
assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD 2'
# call requiring user
add_to_journal.comment = 'HELLO WORLD 3'
trigger.roles = ['logged-users']
workflow.store()
resp = get_app(pub).post(formdata.get_api_url() + 'hooks/plop/', status=403)
resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/'), status=200)
assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD 3'
# call requiring roles
add_to_journal.comment = 'HELLO WORLD 4'
trigger.roles = ['logged-users']
workflow.store()
Role.wipe()
role = Role(name='xxx')
role.store()
trigger.roles = [role.id]
workflow.store()
resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/'), status=403)
resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/', user=local_user), status=403)
local_user.roles = [role.id]
local_user.store()
resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/', user=local_user), status=200)
assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD 4'
def test_tracking_code(pub):
FormDef.wipe()
formdef = FormDef()

View File

@ -593,6 +593,15 @@ def test_global_timeout_trigger(pub):
assert wf2.global_actions[0].triggers[-1].id == trigger.id
assert wf2.global_actions[0].triggers[-1].anchor == trigger.anchor
def test_global_webservice_trigger(pub):
wf = Workflow(name='global actions')
ac1 = wf.add_global_action('Action', 'ac1')
trigger = ac1.append_trigger('webservice')
trigger.identifier = 'plop'
wf2 = assert_import_export_works(wf, include_id=True)
assert wf2.global_actions[0].triggers[-1].id == trigger.id
assert wf2.global_actions[0].triggers[-1].identifier == trigger.identifier
def test_profile_action(pub):
wf = Workflow(name='status')

View File

@ -1272,6 +1272,7 @@ class GlobalActionPage(WorkflowStatusPage):
available_triggers = [
('timeout', _('Automatic')),
('manual', _('Manual')),
('webservice', _('Webservice')),
]
form.add(SingleSelectWidget, 'type', title=_('Type'),
required=True, options=available_triggers)

View File

@ -189,15 +189,25 @@ class ApiFormPage(BackofficeFormPage):
if component == 'ics':
return self.ics()
# check access for all paths, to block access to formdata that would
# otherwise be accessible if the user is the submitter.
self.check_access()
# check access for all paths (except webooks), to block access to
# formdata that would otherwise be accessible if the user is the
# submitter.
if not self.is_webhook:
self.check_access()
try:
formdata = self.formdef.data_class().get(component)
except KeyError:
raise TraversalError()
return ApiFormdataPage(self.formdef, formdata)
def _q_traverse(self, path):
self.is_webhook = False
if len(path) > 1:
# webhooks have their own access checks, request cannot be blocked
# at this point.
self.is_webhook = bool(path[1] == 'hooks')
return super(ApiFormPage, self)._q_traverse(path)
class ApiFormsDirectory(Directory):
_q_exports = ['', 'geojson']

74
wcs/forms/workflows.py Normal file
View File

@ -0,0 +1,74 @@
# w.c.s. - web application for online forms
# Copyright (C) 2005-2019 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 json
from quixote import get_request, get_response
from quixote.directory import Directory
from qommon import errors
from wcs.api import get_user_from_api_query_string, is_url_signed
from wcs.roles import logged_users_role
from wcs.workflows import (
WorkflowGlobalActionWebserviceTrigger,
get_role_translation,
perform_items)
class HookDirectory(Directory):
_q_exports = ['']
def __init__(self, formdata, action, trigger):
self.formdata = formdata
self.action = action
self.trigger = trigger
def _q_index(self):
get_response().set_content_type('application/json')
if not get_request().get_method() == 'POST':
raise errors.AccessForbiddenError('must be POST')
user = get_user_from_api_query_string() or get_request().user
if self.trigger.roles:
for role in self.trigger.roles:
if role == logged_users_role().id and (user or is_url_signed()):
break
if role == '_submitter' and self.formdata.is_submitter(user):
break
if not user:
continue
if get_role_translation(self.formdata, role) in (user.roles or []):
break
else:
raise errors.AccessForbiddenError('insufficient roles')
perform_items(self.action.items, self.formdata)
return json.dumps({'err': 0})
class WorkflowGlobalActionWebserviceHooksDirectory(Directory):
def __init__(self, formdata):
self.formdata = formdata
def _q_lookup(self, component):
for action in self.formdata.formdef.workflow.global_actions:
for trigger in action.triggers or []:
if isinstance(trigger, WorkflowGlobalActionWebserviceTrigger):
if trigger.identifier == component:
return HookDirectory(self.formdata, action, trigger)
raise errors.TraversalError()

View File

@ -432,7 +432,12 @@ class Workflow(StorableObject):
wf_status = formdata.get_status()
if not wf_status: # draft
return []
return wf_status.get_subdirectories(formdata)
directories = []
for action in self.global_actions:
for trigger in action.triggers or []:
directories.extend(trigger.get_subdirectories(formdata))
directories.extend(wf_status.get_subdirectories(formdata))
return directories
def __setstate__(self, dict):
self.__dict__.update(dict)
@ -947,6 +952,9 @@ class WorkflowGlobalActionTrigger(XmlSerialisable):
value = getattr(self, '%s_parse' % f)(value)
setattr(self, f, value)
def get_subdirectories(self, formdata):
return []
class WorkflowGlobalActionManualTrigger(WorkflowGlobalActionTrigger):
key = 'manual'
@ -1156,6 +1164,39 @@ class WorkflowGlobalActionTimeoutTrigger(WorkflowGlobalActionTrigger):
break
class WorkflowGlobalActionWebserviceTrigger(WorkflowGlobalActionManualTrigger):
key = 'webservice'
identifier = None
roles = None
def get_parameters(self):
return ('identifier', 'roles')
def render_as_line(self):
if self.identifier:
return _('Webservice (%s)') % self.identifier
else:
return _('Webservice (not configured)')
def form(self, workflow):
form = Form(enctype='multipart/form-data')
form.add(StringWidget, 'identifier', title=_('Identifier'),
required=True, value=self.identifier)
options = [(None, '---', None)]
options += workflow.get_list_of_roles(include_logged_in_users=True)
form.add(WidgetList, 'roles', title=_('Roles'),
element_type=SingleSelectWidget,
value=self.roles,
add_element_label=_('Add Role'),
element_kwargs={'render_br': False,
'options': options})
return form
def get_subdirectories(self, formdata):
from wcs.forms.workflows import WorkflowGlobalActionWebserviceHooksDirectory
return [('hooks', WorkflowGlobalActionWebserviceHooksDirectory(formdata))]
class WorkflowGlobalAction(object):
id = None
name = None
@ -1183,7 +1224,8 @@ class WorkflowGlobalAction(object):
def append_trigger(self, type):
trigger_types = {
'manual': WorkflowGlobalActionManualTrigger,
'timeout': WorkflowGlobalActionTimeoutTrigger
'timeout': WorkflowGlobalActionTimeoutTrigger,
'webservice': WorkflowGlobalActionWebserviceTrigger,
}
o = trigger_types.get(type)()
if not self.triggers: