--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/hooks/workflow.py Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,357 @@
+# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb 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 Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb. If not, see <http://www.gnu.org/licenses/>.
+"""Core hooks: workflow related hooks"""
+
+__docformat__ = "restructuredtext en"
+from cubicweb import _
+
+from datetime import datetime
+
+
+from cubicweb import RepositoryError, validation_error
+from cubicweb.predicates import is_instance, adaptable
+from cubicweb.server import hook
+
+
+def _change_state(cnx, x, oldstate, newstate):
+ nocheck = cnx.transaction_data.setdefault('skip-security', set())
+ nocheck.add((x, 'in_state', oldstate))
+ nocheck.add((x, 'in_state', newstate))
+ # delete previous state first
+ cnx.delete_relation(x, 'in_state', oldstate)
+ cnx.add_relation(x, 'in_state', newstate)
+
+
+# operations ###################################################################
+
+class _SetInitialStateOp(hook.Operation):
+ """make initial state be a default state"""
+ entity = None # make pylint happy
+
+ def precommit_event(self):
+ cnx = self.cnx
+ entity = self.entity
+ iworkflowable = entity.cw_adapt_to('IWorkflowable')
+ # if there is an initial state and the entity's state is not set,
+ # use the initial state as a default state
+ if not (cnx.deleted_in_transaction(entity.eid) or entity.in_state) \
+ and iworkflowable.current_workflow:
+ state = iworkflowable.current_workflow.initial
+ if state:
+ cnx.add_relation(entity.eid, 'in_state', state.eid)
+ _FireAutotransitionOp(cnx, entity=entity)
+
+class _FireAutotransitionOp(hook.Operation):
+ """try to fire auto transition after state changes"""
+ entity = None # make pylint happy
+
+ def precommit_event(self):
+ entity = self.entity
+ iworkflowable = entity.cw_adapt_to('IWorkflowable')
+ autotrs = list(iworkflowable.possible_transitions('auto'))
+ if autotrs:
+ assert len(autotrs) == 1
+ iworkflowable.fire_transition(autotrs[0])
+
+
+class _WorkflowChangedOp(hook.Operation):
+ """fix entity current state when changing its workflow"""
+ eid = wfeid = None # make pylint happy
+
+ def precommit_event(self):
+ # notice that enforcement that new workflow apply to the entity's type is
+ # done by schema rule, no need to check it here
+ cnx = self.cnx
+ pendingeids = cnx.transaction_data.get('pendingeids', ())
+ if self.eid in pendingeids:
+ return
+ entity = cnx.entity_from_eid(self.eid)
+ iworkflowable = entity.cw_adapt_to('IWorkflowable')
+ # check custom workflow has not been rechanged to another one in the same
+ # transaction
+ mainwf = iworkflowable.main_workflow
+ if mainwf.eid == self.wfeid:
+ deststate = mainwf.initial
+ if not deststate:
+ msg = _('workflow has no initial state')
+ raise validation_error(entity, {('custom_workflow', 'subject'): msg})
+ if mainwf.state_by_eid(iworkflowable.current_state.eid):
+ # nothing to do
+ return
+ # if there are no history, simply go to new workflow's initial state
+ if not iworkflowable.workflow_history:
+ if iworkflowable.current_state.eid != deststate.eid:
+ _change_state(cnx, entity.eid,
+ iworkflowable.current_state.eid, deststate.eid)
+ _FireAutotransitionOp(cnx, entity=entity)
+ return
+ msg = cnx._('workflow changed to "%s"')
+ msg %= cnx._(mainwf.name)
+ cnx.transaction_data[(entity.eid, 'customwf')] = self.wfeid
+ iworkflowable.change_state(deststate, msg, u'text/plain')
+
+
+class _CheckTrExitPoint(hook.Operation):
+ treid = None # make pylint happy
+
+ def precommit_event(self):
+ tr = self.cnx.entity_from_eid(self.treid)
+ outputs = set()
+ for ep in tr.subworkflow_exit:
+ if ep.subwf_state.eid in outputs:
+ msg = _("can't have multiple exits on the same state")
+ raise validation_error(self.treid, {('subworkflow_exit', 'subject'): msg})
+ outputs.add(ep.subwf_state.eid)
+
+
+class _SubWorkflowExitOp(hook.Operation):
+ forentity = trinfo = None # make pylint happy
+
+ def precommit_event(self):
+ cnx = self.cnx
+ forentity = self.forentity
+ iworkflowable = forentity.cw_adapt_to('IWorkflowable')
+ trinfo = self.trinfo
+ # we're in a subworkflow, check if we've reached an exit point
+ wftr = iworkflowable.subworkflow_input_transition()
+ if wftr is None:
+ # inconsistency detected
+ msg = _("state doesn't belong to entity's current workflow")
+ raise validation_error(self.trinfo, {('to_state', 'subject'): msg})
+ tostate = wftr.get_exit_point(forentity, trinfo.cw_attr_cache['to_state'])
+ if tostate is not None:
+ # reached an exit point
+ msg = _('exiting from subworkflow %s')
+ msg %= cnx._(iworkflowable.current_workflow.name)
+ cnx.transaction_data[(forentity.eid, 'subwfentrytr')] = True
+ iworkflowable.change_state(tostate, msg, u'text/plain', tr=wftr)
+
+
+# hooks ########################################################################
+
+class WorkflowHook(hook.Hook):
+ __abstract__ = True
+ category = 'metadata'
+
+
+class SetInitialStateHook(WorkflowHook):
+ __regid__ = 'wfsetinitial'
+ __select__ = WorkflowHook.__select__ & adaptable('IWorkflowable')
+ events = ('after_add_entity',)
+
+ def __call__(self):
+ _SetInitialStateOp(self._cw, entity=self.entity)
+
+
+class FireTransitionHook(WorkflowHook):
+ """check the transition is allowed and add missing information into the
+ TrInfo entity.
+
+ Expect that:
+ * wf_info_for inlined relation is set
+ * by_transition or to_state (managers only) inlined relation is set
+
+ Check for automatic transition to be fired at the end
+ """
+ __regid__ = 'wffiretransition'
+ __select__ = WorkflowHook.__select__ & is_instance('TrInfo')
+ events = ('before_add_entity',)
+
+ def __call__(self):
+ cnx = self._cw
+ entity = self.entity
+ # first retreive entity to which the state change apply
+ try:
+ foreid = entity.cw_attr_cache['wf_info_for']
+ except KeyError:
+ msg = _('mandatory relation')
+ raise validation_error(entity, {('wf_info_for', 'subject'): msg})
+ forentity = cnx.entity_from_eid(foreid)
+ # see comment in the TrInfo entity definition
+ entity.cw_edited['tr_count']=len(forentity.reverse_wf_info_for)
+ iworkflowable = forentity.cw_adapt_to('IWorkflowable')
+ # then check it has a workflow set, unless we're in the process of changing
+ # entity's workflow
+ if cnx.transaction_data.get((forentity.eid, 'customwf')):
+ wfeid = cnx.transaction_data[(forentity.eid, 'customwf')]
+ wf = cnx.entity_from_eid(wfeid)
+ else:
+ wf = iworkflowable.current_workflow
+ if wf is None:
+ msg = _('related entity has no workflow set')
+ raise validation_error(entity, {None: msg})
+ # then check it has a state set
+ fromstate = iworkflowable.current_state
+ if fromstate is None:
+ msg = _('related entity has no state')
+ raise validation_error(entity, {None: msg})
+ # True if we are coming back from subworkflow
+ swtr = cnx.transaction_data.pop((forentity.eid, 'subwfentrytr'), None)
+ cowpowers = (cnx.user.is_in_group('managers')
+ or not cnx.write_security)
+ # no investigate the requested state change...
+ try:
+ treid = entity.cw_attr_cache['by_transition']
+ except KeyError:
+ # no transition set, check user is a manager and destination state
+ # is specified (and valid)
+ if not cowpowers:
+ msg = _('mandatory relation')
+ raise validation_error(entity, {('by_transition', 'subject'): msg})
+ deststateeid = entity.cw_attr_cache.get('to_state')
+ if not deststateeid:
+ msg = _('mandatory relation')
+ raise validation_error(entity, {('by_transition', 'subject'): msg})
+ deststate = wf.state_by_eid(deststateeid)
+ if deststate is None:
+ msg = _("state doesn't belong to entity's workflow")
+ raise validation_error(entity, {('to_state', 'subject'): msg})
+ else:
+ # check transition is valid and allowed, unless we're coming back
+ # from subworkflow
+ tr = cnx.entity_from_eid(treid)
+ if swtr is None:
+ qname = ('by_transition', 'subject')
+ if tr is None:
+ msg = _("transition doesn't belong to entity's workflow")
+ raise validation_error(entity, {qname: msg})
+ if not tr.has_input_state(fromstate):
+ msg = _("transition %(tr)s isn't allowed from %(st)s")
+ raise validation_error(entity, {qname: msg}, {
+ 'tr': tr.name, 'st': fromstate.name}, ['tr', 'st'])
+ if not tr.may_be_fired(foreid):
+ msg = _("transition may not be fired")
+ raise validation_error(entity, {qname: msg})
+ deststateeid = entity.cw_attr_cache.get('to_state')
+ if deststateeid is not None:
+ if not cowpowers and deststateeid != tr.destination(forentity).eid:
+ msg = _("transition isn't allowed")
+ raise validation_error(entity, {('by_transition', 'subject'): msg})
+ if swtr is None:
+ deststate = cnx.entity_from_eid(deststateeid)
+ if not cowpowers and deststate is None:
+ msg = _("state doesn't belong to entity's workflow")
+ raise validation_error(entity, {('to_state', 'subject'): msg})
+ else:
+ deststateeid = tr.destination(forentity).eid
+ # everything is ok, add missing information on the trinfo entity
+ entity.cw_edited['from_state'] = fromstate.eid
+ entity.cw_edited['to_state'] = deststateeid
+ nocheck = cnx.transaction_data.setdefault('skip-security', set())
+ nocheck.add((entity.eid, 'from_state', fromstate.eid))
+ nocheck.add((entity.eid, 'to_state', deststateeid))
+ _FireAutotransitionOp(cnx, entity=forentity)
+
+
+class FiredTransitionHook(WorkflowHook):
+ """change related entity state and handle exit of subworkflow"""
+ __regid__ = 'wffiretransition'
+ __select__ = WorkflowHook.__select__ & is_instance('TrInfo')
+ events = ('after_add_entity',)
+
+ def __call__(self):
+ trinfo = self.entity
+ rcache = trinfo.cw_attr_cache
+ _change_state(self._cw, rcache['wf_info_for'], rcache['from_state'],
+ rcache['to_state'])
+ forentity = self._cw.entity_from_eid(rcache['wf_info_for'])
+ iworkflowable = forentity.cw_adapt_to('IWorkflowable')
+ assert iworkflowable.current_state.eid == rcache['to_state']
+ if iworkflowable.main_workflow.eid != iworkflowable.current_workflow.eid:
+ _SubWorkflowExitOp(self._cw, forentity=forentity, trinfo=trinfo)
+
+
+class CheckInStateChangeAllowed(WorkflowHook):
+ """check state apply, in case of direct in_state change using unsafe execute
+ """
+ __regid__ = 'wfcheckinstate'
+ __select__ = WorkflowHook.__select__ & hook.match_rtype('in_state')
+ events = ('before_add_relation',)
+ category = 'integrity'
+
+ def __call__(self):
+ cnx = self._cw
+ nocheck = cnx.transaction_data.get('skip-security', ())
+ if (self.eidfrom, 'in_state', self.eidto) in nocheck:
+ # state changed through TrInfo insertion, so we already know it's ok
+ return
+ entity = cnx.entity_from_eid(self.eidfrom)
+ iworkflowable = entity.cw_adapt_to('IWorkflowable')
+ mainwf = iworkflowable.main_workflow
+ if mainwf is None:
+ msg = _('entity has no workflow set')
+ raise validation_error(entity, {None: msg})
+ for wf in mainwf.iter_workflows():
+ if wf.state_by_eid(self.eidto):
+ break
+ else:
+ msg = _("state doesn't belong to entity's workflow. You may "
+ "want to set a custom workflow for this entity first.")
+ raise validation_error(self.eidfrom, {('in_state', 'subject'): msg})
+ if iworkflowable.current_workflow and wf.eid != iworkflowable.current_workflow.eid:
+ msg = _("state doesn't belong to entity's current workflow")
+ raise validation_error(self.eidfrom, {('in_state', 'subject'): msg})
+
+
+class SetModificationDateOnStateChange(WorkflowHook):
+ """update entity's modification date after changing its state"""
+ __regid__ = 'wfsyncmdate'
+ __select__ = WorkflowHook.__select__ & hook.match_rtype('in_state')
+ events = ('after_add_relation',)
+
+ def __call__(self):
+ if self._cw.added_in_transaction(self.eidfrom):
+ # new entity, not needed
+ return
+ entity = self._cw.entity_from_eid(self.eidfrom)
+ try:
+ entity.cw_set(modification_date=datetime.utcnow())
+ except RepositoryError as ex:
+ # usually occurs if entity is coming from a read-only source
+ # (eg ldap user)
+ self.warning('cant change modification date for %s: %s', entity, ex)
+
+
+class CheckWorkflowTransitionExitPoint(WorkflowHook):
+ """check that there is no multiple exits from the same state"""
+ __regid__ = 'wfcheckwftrexit'
+ __select__ = WorkflowHook.__select__ & hook.match_rtype('subworkflow_exit')
+ events = ('after_add_relation',)
+
+ def __call__(self):
+ _CheckTrExitPoint(self._cw, treid=self.eidfrom)
+
+
+class SetCustomWorkflow(WorkflowHook):
+ __regid__ = 'wfsetcustom'
+ __select__ = WorkflowHook.__select__ & hook.match_rtype('custom_workflow')
+ events = ('after_add_relation',)
+
+ def __call__(self):
+ _WorkflowChangedOp(self._cw, eid=self.eidfrom, wfeid=self.eidto)
+
+
+class DelCustomWorkflow(SetCustomWorkflow):
+ __regid__ = 'wfdelcustom'
+ events = ('after_delete_relation',)
+
+ def __call__(self):
+ entity = self._cw.entity_from_eid(self.eidfrom)
+ typewf = entity.cw_adapt_to('IWorkflowable').cwetype_workflow()
+ if typewf is not None:
+ _WorkflowChangedOp(self._cw, eid=self.eidfrom, wfeid=typewf.eid)