379 lines
14 KiB
Python
379 lines
14 KiB
Python
# w.c.s. - web application for online forms
|
|
# Copyright (C) 2005-2013 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 contextlib
|
|
import datetime
|
|
import itertools
|
|
import json
|
|
import math
|
|
import os
|
|
import time
|
|
|
|
from quixote import get_publisher, get_request, get_response, redirect
|
|
from quixote.directory import Directory
|
|
from quixote.html import htmltext
|
|
|
|
from wcs.api import get_user_from_api_query_string, is_url_signed
|
|
from wcs.conditions import Condition
|
|
from wcs.workflows import Workflow, WorkflowGlobalAction, WorkflowStatusJumpItem, register_item_class
|
|
|
|
from ..qommon import _, errors, force_str
|
|
from ..qommon.cron import CronJob
|
|
from ..qommon.form import ComputedExpressionWidget, SingleSelectWidget, StringWidget, WidgetList
|
|
from ..qommon.humantime import humanduration2seconds, seconds2humanduration, timewords
|
|
from ..qommon.publisher import get_publisher_class
|
|
from ..qommon.storage import Equal, LessOrEqual
|
|
from ..qommon.template import Template
|
|
|
|
JUMP_TIMEOUT_INTERVAL = max((60 // int(os.environ.get('WCS_JUMP_TIMEOUT_CHECKS', '3')), 1))
|
|
|
|
|
|
def jump_and_perform(formdata, action, workflow_data=None, event=None):
|
|
action.handle_markers_stack(formdata)
|
|
if workflow_data:
|
|
formdata.update_workflow_data(workflow_data)
|
|
formdata.store()
|
|
formdata.jump_status(action.status)
|
|
url = formdata.perform_workflow(event=event)
|
|
return url
|
|
|
|
|
|
class JumpDirectory(Directory):
|
|
_q_exports = ['trigger']
|
|
|
|
def __init__(self, formdata, wfstatusitem, wfstatus):
|
|
self.formdata = formdata
|
|
self.wfstatusitem = wfstatusitem
|
|
self.wfstatus = wfstatus
|
|
self.trigger = TriggerDirectory(formdata, wfstatusitem, wfstatus)
|
|
|
|
|
|
class TriggerDirectory(Directory):
|
|
def __init__(self, formdata, wfstatusitem, wfstatus):
|
|
self.formdata = formdata
|
|
self.wfstatusitem = wfstatusitem
|
|
self.wfstatus = wfstatus
|
|
|
|
def _q_lookup(self, component):
|
|
if get_request().is_json():
|
|
get_response().set_content_type('application/json')
|
|
|
|
if not get_request().get_method() == 'POST':
|
|
raise errors.AccessForbiddenError()
|
|
|
|
signed_request = is_url_signed()
|
|
user = get_user_from_api_query_string() or get_request().user
|
|
for item in self.wfstatus.items:
|
|
if not isinstance(item, JumpWorkflowStatusItem):
|
|
continue
|
|
if not hasattr(item, 'trigger'):
|
|
continue
|
|
if component == item.trigger:
|
|
if signed_request and not item.by:
|
|
pass
|
|
elif not item.check_auth(self.formdata, user):
|
|
raise errors.AccessForbiddenError()
|
|
if item.must_jump(self.formdata, trigger=item.trigger):
|
|
workflow_data = None
|
|
if hasattr(get_request(), '_json'):
|
|
workflow_data = get_request().json
|
|
url = jump_and_perform(
|
|
self.formdata, item, workflow_data=workflow_data, event=('api-trigger', item.trigger)
|
|
)
|
|
else:
|
|
if get_request().is_json():
|
|
get_response().status_code = 403
|
|
return json.dumps({'err': 1, 'err_desc': 'unmet condition'})
|
|
else:
|
|
raise errors.AccessForbiddenError()
|
|
|
|
if get_request().is_json():
|
|
return json.dumps({'err': 0, 'url': url})
|
|
elif url:
|
|
return redirect(url)
|
|
else:
|
|
return redirect(self.formdata.get_url())
|
|
# no trigger found
|
|
raise errors.TraversalError()
|
|
|
|
def _q_traverse(self, path):
|
|
# remove trailing slash from path
|
|
if path[-1] == '':
|
|
path = path[:-1]
|
|
return super()._q_traverse(path)
|
|
|
|
|
|
class JumpWorkflowStatusItem(WorkflowStatusJumpItem):
|
|
description = _('Automatic Jump')
|
|
key = 'jump'
|
|
|
|
by = []
|
|
condition = None
|
|
trigger = None
|
|
timeout = None
|
|
_granularity = JUMP_TIMEOUT_INTERVAL * 60
|
|
|
|
directory_name = 'jump'
|
|
directory_class = JumpDirectory
|
|
|
|
def timeout_init_with_xml(self, elem, charset, include_id=False, snapshot=False):
|
|
if elem is None or elem.text is None:
|
|
self.timeout = None
|
|
else:
|
|
timeout = force_str(elem.text)
|
|
if self.get_expression(timeout)['type'] != 'text':
|
|
self.timeout = timeout
|
|
else:
|
|
self.timeout = int(timeout)
|
|
|
|
def migrate(self):
|
|
changed = super().migrate()
|
|
if isinstance(self.condition, str):
|
|
if self.condition:
|
|
self.condition = {'type': 'python', 'value': self.condition}
|
|
else:
|
|
self.condition = {}
|
|
changed = True
|
|
return changed
|
|
|
|
@property
|
|
def waitpoint(self):
|
|
if self.timeout or self.trigger:
|
|
return True
|
|
return False
|
|
|
|
def render_as_line(self):
|
|
# override parent method to avoid mentioning the condition twice.
|
|
return '%s (%s)' % (self.description, self.get_line_details())
|
|
|
|
def get_line_details(self):
|
|
if not self.status:
|
|
return _('not completed')
|
|
wf_status = self.get_target_status()
|
|
if not wf_status:
|
|
return _('broken')
|
|
reasons = []
|
|
if self.condition and self.condition.get('value'):
|
|
reasons.append(_('condition'))
|
|
if self.trigger:
|
|
reasons.append(_('trigger'))
|
|
if self.timeout:
|
|
reasons.append(_('timeout'))
|
|
|
|
if reasons:
|
|
return _('to %(name)s, %(reasons)s') % {
|
|
'name': wf_status[0].name,
|
|
'reasons': ', '.join([str(x) for x in reasons]),
|
|
}
|
|
else:
|
|
return wf_status[0].name
|
|
|
|
def get_parameters(self):
|
|
if hasattr(self, 'parent') and isinstance(self.parent, WorkflowGlobalAction):
|
|
return ('status', 'condition', 'by', 'timeout', 'set_marker_on_status')
|
|
return ('status', 'condition', 'trigger', 'by', 'timeout', 'set_marker_on_status')
|
|
|
|
def add_parameters_widgets(self, form, parameters, prefix='', formdef=None, **kwargs):
|
|
super().add_parameters_widgets(form, parameters, prefix, formdef, **kwargs)
|
|
if 'condition' in parameters:
|
|
form.get_widget('%scondition' % prefix).advanced = False
|
|
if 'trigger' in parameters:
|
|
form.add(
|
|
StringWidget,
|
|
'%strigger' % prefix,
|
|
title=_('Identifier for webservice'),
|
|
hint=_(
|
|
'This jump will be triggered by an authorized call '
|
|
'to <form_url>/jump/trigger/<identifier>/.'
|
|
),
|
|
value=self.trigger,
|
|
size=40,
|
|
)
|
|
if 'by' in parameters:
|
|
form.add(
|
|
WidgetList,
|
|
'%sby' % prefix,
|
|
title=_('Roles allowed to trigger'),
|
|
element_type=SingleSelectWidget,
|
|
value=self.by,
|
|
add_element_label=self.get_add_role_label(),
|
|
element_kwargs={
|
|
'render_br': False,
|
|
'options': [(None, '---', None)] + self.get_list_of_roles(include_logged_in_users=False),
|
|
},
|
|
)
|
|
if 'timeout' in parameters:
|
|
_hint = htmltext(
|
|
_(
|
|
"ex.: 7 days 1 minute<br/> Usable units of time: %(variables)s. "
|
|
'<br/><span class="warning">Minimal duration is %(granularity)s</span>'
|
|
)
|
|
) % {'variables': ', '.join(timewords()), 'granularity': seconds2humanduration(self._granularity)}
|
|
if not isinstance(self.timeout, int) and self.get_expression(self.timeout)['type'] != 'text':
|
|
form.add(
|
|
ComputedExpressionWidget,
|
|
'%stimeout' % prefix,
|
|
title=_('Timeout'),
|
|
value=self.timeout,
|
|
hint=_hint,
|
|
)
|
|
else:
|
|
form.add(
|
|
StringWidget,
|
|
'%stimeout' % prefix,
|
|
title=_('Timeout'),
|
|
value=seconds2humanduration(self.timeout),
|
|
hint=_hint,
|
|
)
|
|
|
|
def timeout_parse(self, value):
|
|
if not value:
|
|
return value
|
|
if self.get_expression(value)['type'] != 'text':
|
|
return value
|
|
try:
|
|
return humanduration2seconds(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
def perform(self, formdata):
|
|
if not self.status:
|
|
return
|
|
|
|
if self.must_jump(formdata):
|
|
wf_status = self.get_target_status(formdata)
|
|
if wf_status:
|
|
self.handle_markers_stack(formdata)
|
|
formdata.status = 'wf-%s' % wf_status[0].id
|
|
|
|
def check_condition(self, formdata, *args, **kwargs):
|
|
# ship condition check here so it is not evaluated twice.
|
|
return True
|
|
|
|
def must_jump(self, formdata, trigger=None):
|
|
must_jump = True
|
|
|
|
if self.condition:
|
|
context = {'formdata': formdata, 'status_item': self}
|
|
try:
|
|
must_jump = Condition(self.condition, context).evaluate()
|
|
except RuntimeError:
|
|
must_jump = False
|
|
|
|
if self.trigger:
|
|
triggered = trigger is not None and trigger == self.trigger
|
|
must_jump = must_jump and triggered
|
|
|
|
if self.timeout:
|
|
timeout = float(self.compute(self.timeout))
|
|
last = formdata.last_update_time
|
|
if last:
|
|
diff = time.time() - time.mktime(last)
|
|
must_jump = (diff > timeout) and must_jump
|
|
|
|
return must_jump
|
|
|
|
|
|
register_item_class(JumpWorkflowStatusItem)
|
|
|
|
|
|
def workflows_with_timeout():
|
|
"""Returns {workflow id: {status id: [jump_item...]}}"""
|
|
wfs_status = {}
|
|
|
|
for workflow_id in Workflow.keys():
|
|
workflow = Workflow.get(workflow_id, ignore_errors=True)
|
|
if not workflow:
|
|
continue
|
|
for status in workflow.possible_status:
|
|
status_str_id = 'wf-%s' % status.id
|
|
for item in status.items:
|
|
if hasattr(item, 'status') and hasattr(item, 'timeout') and (item.status and item.timeout):
|
|
if workflow_id not in wfs_status:
|
|
wfs_status[workflow_id] = {}
|
|
if not item.get_target_status():
|
|
# this will catch status being a removed status
|
|
continue
|
|
if status_str_id not in wfs_status[workflow_id]:
|
|
wfs_status[workflow_id][status_str_id] = []
|
|
wfs_status[workflow_id][status_str_id].append(item)
|
|
|
|
return wfs_status
|
|
|
|
|
|
def _apply_timeouts(publisher, **kwargs):
|
|
'''Traverse all filled form and apply expired timeout jumps if needed'''
|
|
from ..carddef import CardDef
|
|
from ..formdef import FormDef
|
|
|
|
wfs_status = workflows_with_timeout()
|
|
job = kwargs.pop('job', None)
|
|
|
|
for formdef in itertools.chain(FormDef.select(ignore_errors=True), CardDef.select(ignore_errors=True)):
|
|
status_ids = wfs_status.get(str(formdef.workflow_id))
|
|
if not status_ids:
|
|
continue
|
|
with job.log_long_job(
|
|
'%s %s' % (formdef.xml_root_node, formdef.url_name)
|
|
) if job else contextlib.ExitStack():
|
|
formdata_class = formdef.data_class()
|
|
for status_id in status_ids:
|
|
if publisher.is_using_postgresql():
|
|
# get minimum delay for jumps in this status
|
|
delay = math.inf
|
|
for jump_action in wfs_status[str(formdef.workflow_id)][status_id]:
|
|
if Template.is_template_string(jump_action.timeout):
|
|
delay = 0
|
|
break
|
|
delay = min(delay, int(jump_action.timeout))
|
|
# limit delay to minimal delay
|
|
if delay < JUMP_TIMEOUT_INTERVAL * 60:
|
|
delay = JUMP_TIMEOUT_INTERVAL * 60
|
|
|
|
criterias = [
|
|
Equal('status', status_id),
|
|
LessOrEqual(
|
|
'last_update_time',
|
|
(datetime.datetime.now() - datetime.timedelta(seconds=delay)).timetuple(),
|
|
),
|
|
]
|
|
formdatas = formdata_class.select_iterator(criterias, ignore_errors=True)
|
|
else:
|
|
formdatas = formdata_class.get_with_indexed_value('status', status_id, ignore_errors=True)
|
|
|
|
for formdata in formdatas:
|
|
for jump_action in wfs_status[str(formdef.workflow_id)][formdata.status]:
|
|
get_publisher().substitutions.reset()
|
|
get_publisher().substitutions.feed(get_publisher())
|
|
get_publisher().substitutions.feed(formdef)
|
|
get_publisher().substitutions.feed(formdata)
|
|
if jump_action.must_jump(formdata):
|
|
jump_and_perform(formdata, jump_action, event=('timeout-jump', jump_action.id))
|
|
break
|
|
|
|
|
|
def register_cronjob():
|
|
# every JUMP_TIMEOUT_INTERVAL minutes check for expired status jump
|
|
# timeouts.
|
|
get_publisher_class().register_cronjob(
|
|
CronJob(
|
|
_apply_timeouts,
|
|
name='evaluate_jumps',
|
|
hours=range(24),
|
|
minutes=range(0, 60, JUMP_TIMEOUT_INTERVAL),
|
|
)
|
|
)
|