diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/hooks/workflow.py --- /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 . +"""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)