api: add triggerable global actions to card data api (#88875)

This commit is contained in:
Corentin Sechet 2024-03-24 18:04:05 +01:00 committed by Corentin Sechet
parent ab9adecf55
commit 8779eea796
7 changed files with 153 additions and 15 deletions

View File

@ -344,6 +344,17 @@ ladresse.
contenu des champs de type « Fichier » nest pas exporté.
</p>
<p>
Un paramètre <code>include-actions</code> permet dinclure (<code>on</code>) ou
non (<code>off</code>) la liste des actions globales et des déclencheurs de
sauts automatiques actuellement accessible via l'API à l'utilisateur qui
effectue la requête.
</p>
<screen>
<input>GET https://www.example.net/api/forms/inscriptions/list?include-actions=on</input>
</screen>
</section>
<section>

View File

@ -10,6 +10,7 @@ from wcs.api_access import ApiAccess
from wcs.carddef import CardDef
from wcs.qommon.http_request import HTTPRequest
from wcs.qommon.upload_storage import PicklableUpload
from wcs.workflows import Workflow
from ..utilities import clean_temporary_pub, create_temporary_pub, get_app
from .utils import sign_uri
@ -175,6 +176,8 @@ def test_carddata_include_params(pub, local_user, auth):
resp = get_url('/api/cards/test/list?include-workflow-data=on')
assert 'workflow' in resp.json['data'][0]
assert 'data' in resp.json['data'][0]['workflow']
resp = get_url('/api/cards/test/list?include-actions=on')
assert 'actions' in resp.json['data'][0]
resp = get_url('/api/cards/test/%s/?include-fields=off' % carddata.id)
assert 'fields' not in resp.json
@ -192,6 +195,8 @@ def test_carddata_include_params(pub, local_user, auth):
assert 'data' not in resp.json['workflow']
resp = get_url('/api/cards/test/%s/?include-workflow=off&include-workflow-data=off' % carddata.id)
assert 'workflow' not in resp.json
resp = get_url('/api/cards/test/%s/?include-actions=off' % carddata.id)
assert 'actions' not in resp.json
resp = get_url('/api/cards/test/list')
assert len(resp.json['data']) == 1
@ -499,3 +504,86 @@ def test_api_card_list_custom_id_filter_identifier(pub):
assert len(resp.json['data']) == 1
assert resp.json['data'][0]['id'] == 'bar'
assert resp.json['data'][0]['internal_id'] == str(card.id)
@pytest.mark.parametrize('auth', ['signature', 'http-basic'])
def test_carddata_global_actions(auth, pub, local_user):
CardDef.wipe()
Workflow.wipe()
ApiAccess.wipe()
pub.role_class.wipe()
role = pub.role_class(name='allowed-action-role')
role.store()
local_user.roles = [role.id]
local_user.store()
access = ApiAccess()
access.name = 'test'
access.access_identifier = 'test'
access.access_key = '12345'
access.store()
app = get_app(pub)
if auth == 'http-basic':
access.roles = [role]
access.store()
def get_url(url, **kwargs):
app.set_authorization(('Basic', ('test', '12345')))
return app.get(url, **kwargs)
else:
def get_url(url, **kwargs):
return app.get(
sign_uri(
url,
user=local_user,
orig=access.access_identifier,
key=access.access_key,
),
**kwargs,
)
workflow = Workflow(name='test-workflow')
workflow.add_status('test-status')
action = workflow.add_global_action('Global Action')
trigger = action.append_trigger('webservice')
trigger.identifier = 'test-trigger'
trigger.roles = [role.id]
workflow.store()
CardDef.wipe()
carddef = CardDef()
carddef.name = 'test-carddef'
carddef.workflow_id = workflow.id
carddef.workflow_roles = {'_receiver': role.id}
carddef.store()
carddata = carddef.data_class()()
carddata.just_created()
carddata.jump_status('workflow-status')
carddata.store()
resp = get_url('/api/cards/test-carddef/%s/?include-actions=on' % carddata.id)
assert resp.json['actions'] == {
'global-action:test-trigger': f'{carddata.get_api_url()}hooks/test-trigger/'
}
trigger.identifier = None
workflow.store()
resp = get_url('/api/cards/test-carddef/%s/?include-actions=on' % carddata.id)
assert resp.json['actions'] == {}
trigger.identifier = 'test-trigger'
trigger.roles = ['_unhautorized']
workflow.store()
resp = get_url('/api/cards/test-carddef/%s/?include-actions=on' % carddata.id)
assert resp.json['actions'] == {}

View File

@ -2618,6 +2618,7 @@ class FormPage(Directory, TempfileDirectoryMixin):
include_submission = get_query_flag('include-submission') or full
include_workflow = get_query_flag('include-workflow') or full
include_workflow_data = get_query_flag('include-workflow-data') or full
include_actions = get_query_flag('include-actions') or full
if include_evolution or include_workflow:
self.formdef.data_class().load_all_evolutions(items)
# noqa pylint: disable=too-many-boolean-expressions
@ -2628,6 +2629,7 @@ class FormPage(Directory, TempfileDirectoryMixin):
or include_submission
or include_workflow
or include_workflow_data
or include_actions
):
job = JsonFileExportAfterJob(self.formdef)
output = job.create_json_export(
@ -2643,6 +2645,7 @@ class FormPage(Directory, TempfileDirectoryMixin):
include_unnamed_fields=False,
include_workflow=include_workflow,
include_workflow_data=include_workflow_data,
include_actions=include_actions,
values_at=get_request().form.get('at'),
)
if job.id:
@ -4467,6 +4470,7 @@ class JsonFileExportAfterJob(CsvExportAfterJob):
include_unnamed_fields,
include_workflow,
include_workflow_data,
include_actions,
values_at=None,
):
# noqa pylint: disable=too-many-arguments
@ -4528,6 +4532,7 @@ class JsonFileExportAfterJob(CsvExportAfterJob):
include_unnamed_fields=include_unnamed_fields,
include_workflow=include_workflow,
include_workflow_data=include_workflow_data,
include_actions=include_actions,
values_at=values_at,
)
except NoContentSnapshotAt:
@ -4555,6 +4560,7 @@ class JsonFileExportAfterJob(CsvExportAfterJob):
include_unnamed_fields=True,
include_workflow=True,
include_workflow_data=True,
include_actions=False,
)
},
indent=2,

View File

@ -1547,6 +1547,7 @@ class FormData(StorableObject):
include_unnamed_fields=False,
include_workflow=True,
include_workflow_data=True,
include_actions=True,
values_at=None,
):
# noqa pylint: disable=too-many-arguments
@ -1647,6 +1648,21 @@ class FormData(StorableObject):
data['workflow'] = {}
data['workflow']['data'] = self.workflow_data
# include actions
if include_actions:
actions = {}
data['actions'] = actions
for trigger in self.formdef.workflow.get_all_global_action_triggers():
if (
trigger.key == 'webservice'
and trigger.identifier
and trigger.check_executable(self, user)
):
actions[
f'global-action:{trigger.identifier}'
] = f'{self.get_api_url()}hooks/{trigger.identifier}/'
if include_roles:
# add a roles dictionary, with workflow functions and two special
# entries for concerned/actions roles.
@ -1720,6 +1736,7 @@ class FormData(StorableObject):
def export_to_json(
self,
anonymise=False,
user=None,
include_evolution=True,
include_files=True,
include_roles=True,
@ -1728,11 +1745,13 @@ class FormData(StorableObject):
include_unnamed_fields=False,
include_workflow=True,
include_workflow_data=True,
include_actions=True,
values_at=None,
):
# noqa pylint: disable=too-many-arguments
data = self.get_json_export_dict(
anonymise=anonymise,
user=user,
include_evolution=include_evolution,
include_files=include_files,
include_roles=include_roles,
@ -1741,6 +1760,7 @@ class FormData(StorableObject):
include_unnamed_fields=include_unnamed_fields,
include_workflow=include_workflow,
include_workflow_data=include_workflow_data,
include_actions=include_actions,
values_at=values_at,
)
return json.dumps(data, cls=misc.JSONEncoder)

View File

@ -245,6 +245,7 @@ class FormStatusPage(Directory, FormTemplateMixin):
include_unnamed_fields=False,
include_workflow=get_query_flag('include-workflow', default=True),
include_workflow_data=get_query_flag('include-workflow-data', default=True),
include_actions=get_query_flag('include-actions', default=False),
values_at=values_at,
)
@ -479,12 +480,15 @@ class FormStatusPage(Directory, FormTemplateMixin):
include_unnamed_fields=False,
include_workflow=True,
include_workflow_data=True,
include_actions=True,
values_at=None,
):
# noqa pylint: disable=too-many-arguments
get_response().set_content_type('application/json')
user = get_user_from_api_query_string() or get_request().user
return self.filled.export_to_json(
anonymise=anonymise,
user=user,
include_evolution=include_evolution,
include_files=include_files,
include_roles=include_roles,
@ -493,6 +497,7 @@ class FormStatusPage(Directory, FormTemplateMixin):
include_unnamed_fields=include_unnamed_fields,
include_workflow=include_workflow,
include_workflow_data=include_workflow_data,
include_actions=include_actions,
values_at=values_at,
)

View File

@ -19,8 +19,7 @@ import json
from quixote import get_request, get_response
from quixote.directory import Directory
from wcs.api import get_user_from_api_query_string, is_url_signed
from wcs.roles import logged_users_role
from wcs.api import get_user_from_api_query_string
from wcs.wf.jump import WorkflowTriggeredEvolutionPart
from wcs.workflows import WorkflowGlobalActionWebserviceTrigger, perform_items, push_perform_workflow
@ -42,19 +41,8 @@ class HookDirectory(Directory):
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 self.formdata.get_function_roles(role).intersection(user.get_roles()):
break
else:
if not ('_signed_calls' in self.trigger.roles and is_url_signed()):
raise errors.AccessForbiddenError('insufficient roles')
if not self.trigger.check_executable(self.formdata, user):
raise errors.AccessForbiddenError('insufficient roles')
workflow_data = get_request().json if hasattr(get_request(), '_json') else None
self.formdata.evolution[-1].add_part(

View File

@ -35,6 +35,7 @@ from quixote import get_publisher, get_request, get_response, get_session
from quixote.html import TemplateIO, htmlescape, htmltext
import wcs.qommon.storage as st
from wcs.api_utils import is_url_signed
from wcs.qommon.storage import StorableObject, atomic_write
from wcs.sql_criterias import Contains, LessOrEqual, Null, StatusReachedTimeoutCriteria, StrictNotEqual
@ -2424,6 +2425,25 @@ class WorkflowGlobalActionWebserviceTrigger(WorkflowGlobalActionManualTrigger):
def get_dependencies(self):
return []
def check_executable(self, formdata, user):
if self.roles is None:
return True
for role in self.roles: # noqa pylint: disable=not-an-iterable
if role == logged_users_role().id and (user or is_url_signed()):
return True
if role == '_submitter' and formdata.is_submitter(user):
return True
if not user:
continue
if formdata.get_function_roles(role).intersection(user.get_roles()):
return True
if '_signed_calls' in self.roles and is_url_signed():
return True
return False
class SerieOfActionsMixin:
items = None