wcs/wcs/wf/external_workflow.py

379 lines
14 KiB
Python

# 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 uuid
from quixote import get_publisher, get_request
from quixote.html import TemplateIO, htmltext
from wcs.carddef import CardDef
from wcs.formdef import FormDef
from wcs.qommon import _
from wcs.qommon.form import ComputedExpressionWidget, RadiobuttonsWidget, SingleSelectWidget
from wcs.variables import LazyFormData, LazyFormDefObjectsManager
from wcs.workflows import (
AbortOnRemovalException,
EvolutionPart,
Workflow,
WorkflowGlobalActionWebserviceTrigger,
WorkflowStatusItem,
perform_items,
push_perform_workflow,
register_item_class,
)
class ManyExternalCallsPart(EvolutionPart):
processed_ids = None
label = None
running = True
uuid = None
def __init__(self, label):
self.label = label
self.uuid = str(uuid.uuid4())
self.processed_ids = []
def is_hidden(self):
return bool(not self.running) or not (
get_request() and get_request().get_path().startswith('/backoffice/')
)
def view(self):
r = TemplateIO(html=True)
r += htmltext('<div>')
r += (
htmltext('<p>%s</p>')
% _('Running external actions on "%(label)s" (%(count)s processed)')
% {'label': self.label, 'count': len(self.processed_ids)}
)
r += htmltext('</div>')
return r.getvalue()
class ExternalWorkflowGlobalAction(WorkflowStatusItem):
description = _('External workflow')
key = 'external_workflow_global_action'
category = 'formdata-action'
automatic_targetting = _('Action on forms/cards linked to this form/card')
manual_targetting = _('Specify the list of forms/cards on which the action will be applied')
slug = None
target_mode = None
target_id = None
trigger_id = None
def get_workflow_webservice_triggers(self, workflow):
for action in workflow.global_actions or []:
for trigger in action.triggers or []:
if isinstance(trigger, WorkflowGlobalActionWebserviceTrigger) and trigger.identifier:
yield trigger
def get_object_def(self, object_slug=None):
slug = object_slug or self.slug
try:
object_type, slug = slug.split(':')
except (AttributeError, ValueError):
return None
if object_type == 'formdef':
object_class = FormDef
elif object_type == 'carddef':
object_class = CardDef
try:
return object_class.get_by_urlname(slug)
except KeyError:
pass
def get_trigger(self, workflow):
try:
trigger_id = self.trigger_id.split(':', 1)[1]
except ValueError:
return
for trigger in self.get_workflow_webservice_triggers(workflow):
if trigger.identifier == trigger_id:
return trigger
def get_inspect_parameters(self):
parameters = super().get_inspect_parameters()
if self.target_mode != 'manual':
parameters.remove('target_id')
return parameters
def add_parameters_widgets(self, form, parameters, prefix='', formdef=None, **kwargs):
super().add_parameters_widgets(form, parameters, prefix=prefix, formdef=formdef, **kwargs)
if 'slug' in parameters or 'trigger_id' in parameters:
objects = [(None, '---', '', {})]
trigger_options = []
is_admin_accessible = {
'forms': get_publisher().get_backoffice_root().is_accessible('forms'),
'cards': get_publisher().get_backoffice_root().is_accessible('cards'),
}
# preload all cards/forms
objectdefs = FormDef.select(order_by='id', lightweight=True) + CardDef.select(
order_by='id', lightweight=True
)
# get workflows with external actions
workflows = {}
for workflow in Workflow.select(ignore_migration=True):
external_triggers = list(self.get_workflow_webservice_triggers(workflow))
if external_triggers:
workflows[workflow.id] = workflow
for trigger in external_triggers:
object_slugs = [
f'{x.__class__.__name__.lower()}:{x.url_name}'
for x in objectdefs
if x.workflow_id == workflow.id
]
trigger_id = 'action:%s' % trigger.identifier
trigger_options.append(
(trigger_id, trigger.parent.name, trigger_id, {'data-slugs': '|'.join(object_slugs)})
)
# list cards/forms with workflows with external actions
for objectdef in objectdefs:
workflow = workflows.get(objectdef.workflow_id)
if not workflow:
continue
object_slug = '%s:%s' % (objectdef.__class__.__name__.lower(), objectdef.url_name)
objects.append((object_slug, objectdef.name, object_slug, {}))
if is_admin_accessible[objectdef.backoffice_section]:
objects[-1][-1]['data-goto-url'] = objectdef.get_admin_url()
if len(objects) == 1:
form.add_global_errors([_('No workflow with external triggerable global action.')])
return
if 'slug' in parameters:
objects.sort(key=lambda x: x[1])
form.add(
SingleSelectWidget,
'%sslug' % prefix,
title=_('Form/Card'),
value=self.slug,
required=True,
options=objects,
**{'data-filter-trigger-select': 'true'},
)
if 'target_mode' in parameters:
target_modes = [
('all', self.automatic_targetting, 'all'),
('manual', self.manual_targetting, 'manual'),
]
form.add(
RadiobuttonsWidget,
'%starget_mode' % prefix,
title=_('Targeting'),
value=self.target_mode or 'all',
required=True,
options=target_modes,
attrs={'data-dynamic-display-parent': 'true'},
)
if 'target_id' in parameters:
form.add(
ComputedExpressionWidget,
'%starget_id' % prefix,
value=self.target_id,
required=False,
attrs={
'data-dynamic-display-child-of': 'target_mode',
'data-dynamic-display-value': 'manual',
},
allow_python=getattr(self, 'allow_python', True),
)
if 'trigger_id' in parameters:
trigger_options.sort(key=lambda x: x[1])
form.add(
SingleSelectWidget,
'%strigger_id' % prefix,
title=_('Action'),
value=self.trigger_id,
required=True,
options=[(None, '---', '', {})] + trigger_options,
)
if kwargs.get('orig') == 'variable_widget':
return
def get_line_details(self):
if self.slug and self.trigger_id:
objectdef = self.get_object_def()
if objectdef:
trigger = self.get_trigger(objectdef.workflow)
if trigger:
return _('action "%(trigger_name)s" on %(object_name)s') % {
'trigger_name': trigger.parent.name,
'object_name': objectdef.name,
}
return _('not completed')
def get_manual_target(self, formdata):
if self.target_mode != 'manual':
return
objectdef = self.get_object_def()
with get_publisher().complex_data():
target_id = self.compute(self.target_id, formdata=formdata, status_item=self, allow_complex=True)
if target_id:
target_id = get_publisher().get_cached_complex_data(target_id)
if isinstance(target_id, LazyFormData):
if target_id._formdef != objectdef:
# abort if it's not the correct formdef/carddef
get_publisher().record_error(
_('Mismatch in target object: expected "%(object_name)s", got "%(object_name2)s"')
% {'object_name': objectdef.name, 'object_name2': target_id._formdef.name},
formdata=formdata,
status_item=self,
)
return
yield target_id._formdata
return
if isinstance(target_id, LazyFormDefObjectsManager):
if target_id._formdef != objectdef:
# abort if it's not the correct formdef/carddef
get_publisher().record_error(
_('Mismatch in target objects: expected "%(object_name)s", got "%(object_name2)s"')
% {'object_name': objectdef.name, 'object_name2': target_id._formdef.name},
formdata=formdata,
status_item=self,
)
return
for lazy_formdata in target_id:
yield lazy_formdata._formdata
return
if not target_id:
return
try:
yield objectdef.data_class().get_by_id(target_id)
except KeyError as e:
# use custom error message depending on target type
get_publisher().record_error(
_('Could not find targeted "%(object_name)s" object by id %(object_id)s')
% {'object_name': objectdef.name, 'object_id': target_id},
formdata=formdata,
status_item=self,
exception=e,
)
def iter_target_datas(self, formdata, objectdef):
if self.target_mode == 'manual':
# return targets
yield from self.get_manual_target(formdata)
else:
yield from formdata.iter_target_datas(
objectdef=objectdef, object_type=self.slug, status_item=self
)
def get_parameters(self):
return ('slug', 'trigger_id', 'target_mode', 'target_id', 'condition')
def get_computed_strings(self):
yield from super().get_computed_strings()
if self.target_mode == 'manual':
yield self.target_id
def perform(self, formdata):
objectdef = self.get_object_def()
if not objectdef:
return
trigger = self.get_trigger(objectdef.workflow)
if not trigger:
get_publisher().record_error(
_('No trigger with id "%s" found in workflow') % self.trigger_id,
formdata=formdata,
status_item=self,
)
return
class CallerSource:
def __init__(self, formdata):
self.formdata = formdata
def get_substitution_variables(self):
return {'caller_form': self.formdata.get_substitution_variables(minimal=True)['form']}
caller_source = CallerSource(formdata)
formdata.store()
status_part = ManyExternalCallsPart(label=objectdef.name)
for i, target_data in enumerate(self.iter_target_datas(formdata, objectdef)):
with get_publisher().substitutions.temporary_feed(target_data), push_perform_workflow(
target_data
):
get_publisher().reset_formdata_state()
get_publisher().substitutions.feed(target_data.formdef)
get_publisher().substitutions.feed(target_data)
get_publisher().substitutions.feed(caller_source)
target_data.record_workflow_event(
'global-external-workflow',
external_workflow_id=trigger.get_workflow().id,
global_action_id=trigger.parent.id,
)
perform_items(
trigger.parent.items,
target_data,
global_action=True,
)
try:
# update local object as it may have been modified by target_data
# workflow executions.
formdata.refresh_from_storage()
except KeyError:
# current carddata/formdata was removed
raise AbortOnRemovalException(formdata)
if i == 0:
# if there are iterations, add tracking status to object
formdata.evolution[-1].add_part(status_part)
elif i:
# get status object back
for evolution in reversed(formdata.evolution):
try:
status_part = [
x
for x in evolution.parts
if isinstance(x, ManyExternalCallsPart) and x.uuid == status_part.uuid
][0]
except IndexError:
# probably the status changed and the tracking object is no longer available,
# do without
continue
break
status_part.processed_ids.append(target_data.get_display_id())
# after iterating, store
formdata.store()
# note it's now done.
status_part.running = False
formdata.store()
register_item_class(ExternalWorkflowGlobalAction)