hooks/workflow.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """Core hooks: workflow related hooks"""
       
    19 
       
    20 __docformat__ = "restructuredtext en"
       
    21 from cubicweb import _
       
    22 
       
    23 from datetime import datetime
       
    24 
       
    25 
       
    26 from cubicweb import RepositoryError, validation_error
       
    27 from cubicweb.predicates import is_instance, adaptable
       
    28 from cubicweb.server import hook
       
    29 
       
    30 
       
    31 def _change_state(cnx, x, oldstate, newstate):
       
    32     nocheck = cnx.transaction_data.setdefault('skip-security', set())
       
    33     nocheck.add((x, 'in_state', oldstate))
       
    34     nocheck.add((x, 'in_state', newstate))
       
    35     # delete previous state first
       
    36     cnx.delete_relation(x, 'in_state', oldstate)
       
    37     cnx.add_relation(x, 'in_state', newstate)
       
    38 
       
    39 
       
    40 # operations ###################################################################
       
    41 
       
    42 class _SetInitialStateOp(hook.Operation):
       
    43     """make initial state be a default state"""
       
    44     entity = None # make pylint happy
       
    45 
       
    46     def precommit_event(self):
       
    47         cnx = self.cnx
       
    48         entity = self.entity
       
    49         iworkflowable = entity.cw_adapt_to('IWorkflowable')
       
    50         # if there is an initial state and the entity's state is not set,
       
    51         # use the initial state as a default state
       
    52         if not (cnx.deleted_in_transaction(entity.eid) or entity.in_state) \
       
    53                and iworkflowable.current_workflow:
       
    54             state = iworkflowable.current_workflow.initial
       
    55             if state:
       
    56                 cnx.add_relation(entity.eid, 'in_state', state.eid)
       
    57                 _FireAutotransitionOp(cnx, entity=entity)
       
    58 
       
    59 class _FireAutotransitionOp(hook.Operation):
       
    60     """try to fire auto transition after state changes"""
       
    61     entity = None # make pylint happy
       
    62 
       
    63     def precommit_event(self):
       
    64         entity = self.entity
       
    65         iworkflowable = entity.cw_adapt_to('IWorkflowable')
       
    66         autotrs = list(iworkflowable.possible_transitions('auto'))
       
    67         if autotrs:
       
    68             assert len(autotrs) == 1
       
    69             iworkflowable.fire_transition(autotrs[0])
       
    70 
       
    71 
       
    72 class _WorkflowChangedOp(hook.Operation):
       
    73     """fix entity current state when changing its workflow"""
       
    74     eid = wfeid = None # make pylint happy
       
    75 
       
    76     def precommit_event(self):
       
    77         # notice that enforcement that new workflow apply to the entity's type is
       
    78         # done by schema rule, no need to check it here
       
    79         cnx = self.cnx
       
    80         pendingeids = cnx.transaction_data.get('pendingeids', ())
       
    81         if self.eid in pendingeids:
       
    82             return
       
    83         entity = cnx.entity_from_eid(self.eid)
       
    84         iworkflowable = entity.cw_adapt_to('IWorkflowable')
       
    85         # check custom workflow has not been rechanged to another one in the same
       
    86         # transaction
       
    87         mainwf = iworkflowable.main_workflow
       
    88         if mainwf.eid == self.wfeid:
       
    89             deststate = mainwf.initial
       
    90             if not deststate:
       
    91                 msg = _('workflow has no initial state')
       
    92                 raise validation_error(entity, {('custom_workflow', 'subject'): msg})
       
    93             if mainwf.state_by_eid(iworkflowable.current_state.eid):
       
    94                 # nothing to do
       
    95                 return
       
    96             # if there are no history, simply go to new workflow's initial state
       
    97             if not iworkflowable.workflow_history:
       
    98                 if iworkflowable.current_state.eid != deststate.eid:
       
    99                     _change_state(cnx, entity.eid,
       
   100                                   iworkflowable.current_state.eid, deststate.eid)
       
   101                     _FireAutotransitionOp(cnx, entity=entity)
       
   102                 return
       
   103             msg = cnx._('workflow changed to "%s"')
       
   104             msg %= cnx._(mainwf.name)
       
   105             cnx.transaction_data[(entity.eid, 'customwf')] = self.wfeid
       
   106             iworkflowable.change_state(deststate, msg, u'text/plain')
       
   107 
       
   108 
       
   109 class _CheckTrExitPoint(hook.Operation):
       
   110     treid = None # make pylint happy
       
   111 
       
   112     def precommit_event(self):
       
   113         tr = self.cnx.entity_from_eid(self.treid)
       
   114         outputs = set()
       
   115         for ep in tr.subworkflow_exit:
       
   116             if ep.subwf_state.eid in outputs:
       
   117                 msg = _("can't have multiple exits on the same state")
       
   118                 raise validation_error(self.treid, {('subworkflow_exit', 'subject'): msg})
       
   119             outputs.add(ep.subwf_state.eid)
       
   120 
       
   121 
       
   122 class _SubWorkflowExitOp(hook.Operation):
       
   123     forentity = trinfo = None # make pylint happy
       
   124 
       
   125     def precommit_event(self):
       
   126         cnx = self.cnx
       
   127         forentity = self.forentity
       
   128         iworkflowable = forentity.cw_adapt_to('IWorkflowable')
       
   129         trinfo = self.trinfo
       
   130         # we're in a subworkflow, check if we've reached an exit point
       
   131         wftr = iworkflowable.subworkflow_input_transition()
       
   132         if wftr is None:
       
   133             # inconsistency detected
       
   134             msg = _("state doesn't belong to entity's current workflow")
       
   135             raise validation_error(self.trinfo, {('to_state', 'subject'): msg})
       
   136         tostate = wftr.get_exit_point(forentity, trinfo.cw_attr_cache['to_state'])
       
   137         if tostate is not None:
       
   138             # reached an exit point
       
   139             msg = _('exiting from subworkflow %s')
       
   140             msg %= cnx._(iworkflowable.current_workflow.name)
       
   141             cnx.transaction_data[(forentity.eid, 'subwfentrytr')] = True
       
   142             iworkflowable.change_state(tostate, msg, u'text/plain', tr=wftr)
       
   143 
       
   144 
       
   145 # hooks ########################################################################
       
   146 
       
   147 class WorkflowHook(hook.Hook):
       
   148     __abstract__ = True
       
   149     category = 'metadata'
       
   150 
       
   151 
       
   152 class SetInitialStateHook(WorkflowHook):
       
   153     __regid__ = 'wfsetinitial'
       
   154     __select__ = WorkflowHook.__select__ & adaptable('IWorkflowable')
       
   155     events = ('after_add_entity',)
       
   156 
       
   157     def __call__(self):
       
   158         _SetInitialStateOp(self._cw, entity=self.entity)
       
   159 
       
   160 
       
   161 class FireTransitionHook(WorkflowHook):
       
   162     """check the transition is allowed and add missing information into the
       
   163     TrInfo entity.
       
   164 
       
   165     Expect that:
       
   166     * wf_info_for inlined relation is set
       
   167     * by_transition or to_state (managers only) inlined relation is set
       
   168 
       
   169     Check for automatic transition to be fired at the end
       
   170     """
       
   171     __regid__ = 'wffiretransition'
       
   172     __select__ = WorkflowHook.__select__ & is_instance('TrInfo')
       
   173     events = ('before_add_entity',)
       
   174 
       
   175     def __call__(self):
       
   176         cnx = self._cw
       
   177         entity = self.entity
       
   178         # first retreive entity to which the state change apply
       
   179         try:
       
   180             foreid = entity.cw_attr_cache['wf_info_for']
       
   181         except KeyError:
       
   182             msg = _('mandatory relation')
       
   183             raise validation_error(entity, {('wf_info_for', 'subject'): msg})
       
   184         forentity = cnx.entity_from_eid(foreid)
       
   185         # see comment in the TrInfo entity definition
       
   186         entity.cw_edited['tr_count']=len(forentity.reverse_wf_info_for)
       
   187         iworkflowable = forentity.cw_adapt_to('IWorkflowable')
       
   188         # then check it has a workflow set, unless we're in the process of changing
       
   189         # entity's workflow
       
   190         if cnx.transaction_data.get((forentity.eid, 'customwf')):
       
   191             wfeid = cnx.transaction_data[(forentity.eid, 'customwf')]
       
   192             wf = cnx.entity_from_eid(wfeid)
       
   193         else:
       
   194             wf = iworkflowable.current_workflow
       
   195         if wf is None:
       
   196             msg = _('related entity has no workflow set')
       
   197             raise validation_error(entity, {None: msg})
       
   198         # then check it has a state set
       
   199         fromstate = iworkflowable.current_state
       
   200         if fromstate is None:
       
   201             msg = _('related entity has no state')
       
   202             raise validation_error(entity, {None: msg})
       
   203         # True if we are coming back from subworkflow
       
   204         swtr = cnx.transaction_data.pop((forentity.eid, 'subwfentrytr'), None)
       
   205         cowpowers = (cnx.user.is_in_group('managers')
       
   206                      or not cnx.write_security)
       
   207         # no investigate the requested state change...
       
   208         try:
       
   209             treid = entity.cw_attr_cache['by_transition']
       
   210         except KeyError:
       
   211             # no transition set, check user is a manager and destination state
       
   212             # is specified (and valid)
       
   213             if not cowpowers:
       
   214                 msg = _('mandatory relation')
       
   215                 raise validation_error(entity, {('by_transition', 'subject'): msg})
       
   216             deststateeid = entity.cw_attr_cache.get('to_state')
       
   217             if not deststateeid:
       
   218                 msg = _('mandatory relation')
       
   219                 raise validation_error(entity, {('by_transition', 'subject'): msg})
       
   220             deststate = wf.state_by_eid(deststateeid)
       
   221             if deststate is None:
       
   222                 msg = _("state doesn't belong to entity's workflow")
       
   223                 raise validation_error(entity, {('to_state', 'subject'): msg})
       
   224         else:
       
   225             # check transition is valid and allowed, unless we're coming back
       
   226             # from subworkflow
       
   227             tr = cnx.entity_from_eid(treid)
       
   228             if swtr is None:
       
   229                 qname = ('by_transition', 'subject')
       
   230                 if tr is None:
       
   231                     msg = _("transition doesn't belong to entity's workflow")
       
   232                     raise validation_error(entity, {qname: msg})
       
   233                 if not tr.has_input_state(fromstate):
       
   234                     msg = _("transition %(tr)s isn't allowed from %(st)s")
       
   235                     raise validation_error(entity, {qname: msg}, {
       
   236                             'tr': tr.name, 'st': fromstate.name}, ['tr', 'st'])
       
   237                 if not tr.may_be_fired(foreid):
       
   238                     msg = _("transition may not be fired")
       
   239                     raise validation_error(entity, {qname: msg})
       
   240             deststateeid = entity.cw_attr_cache.get('to_state')
       
   241             if deststateeid is not None:
       
   242                 if not cowpowers and deststateeid != tr.destination(forentity).eid:
       
   243                     msg = _("transition isn't allowed")
       
   244                     raise validation_error(entity, {('by_transition', 'subject'): msg})
       
   245                 if swtr is None:
       
   246                     deststate = cnx.entity_from_eid(deststateeid)
       
   247                     if not cowpowers and deststate is None:
       
   248                         msg = _("state doesn't belong to entity's workflow")
       
   249                         raise validation_error(entity, {('to_state', 'subject'): msg})
       
   250             else:
       
   251                 deststateeid = tr.destination(forentity).eid
       
   252         # everything is ok, add missing information on the trinfo entity
       
   253         entity.cw_edited['from_state'] = fromstate.eid
       
   254         entity.cw_edited['to_state'] = deststateeid
       
   255         nocheck = cnx.transaction_data.setdefault('skip-security', set())
       
   256         nocheck.add((entity.eid, 'from_state', fromstate.eid))
       
   257         nocheck.add((entity.eid, 'to_state', deststateeid))
       
   258         _FireAutotransitionOp(cnx, entity=forentity)
       
   259 
       
   260 
       
   261 class FiredTransitionHook(WorkflowHook):
       
   262     """change related entity state and handle exit of subworkflow"""
       
   263     __regid__ = 'wffiretransition'
       
   264     __select__ = WorkflowHook.__select__ & is_instance('TrInfo')
       
   265     events = ('after_add_entity',)
       
   266 
       
   267     def __call__(self):
       
   268         trinfo = self.entity
       
   269         rcache = trinfo.cw_attr_cache
       
   270         _change_state(self._cw, rcache['wf_info_for'], rcache['from_state'],
       
   271                       rcache['to_state'])
       
   272         forentity = self._cw.entity_from_eid(rcache['wf_info_for'])
       
   273         iworkflowable = forentity.cw_adapt_to('IWorkflowable')
       
   274         assert iworkflowable.current_state.eid == rcache['to_state']
       
   275         if iworkflowable.main_workflow.eid != iworkflowable.current_workflow.eid:
       
   276             _SubWorkflowExitOp(self._cw, forentity=forentity, trinfo=trinfo)
       
   277 
       
   278 
       
   279 class CheckInStateChangeAllowed(WorkflowHook):
       
   280     """check state apply, in case of direct in_state change using unsafe execute
       
   281     """
       
   282     __regid__ = 'wfcheckinstate'
       
   283     __select__ = WorkflowHook.__select__ & hook.match_rtype('in_state')
       
   284     events = ('before_add_relation',)
       
   285     category = 'integrity'
       
   286 
       
   287     def __call__(self):
       
   288         cnx = self._cw
       
   289         nocheck = cnx.transaction_data.get('skip-security', ())
       
   290         if (self.eidfrom, 'in_state', self.eidto) in nocheck:
       
   291             # state changed through TrInfo insertion, so we already know it's ok
       
   292             return
       
   293         entity = cnx.entity_from_eid(self.eidfrom)
       
   294         iworkflowable = entity.cw_adapt_to('IWorkflowable')
       
   295         mainwf = iworkflowable.main_workflow
       
   296         if mainwf is None:
       
   297             msg = _('entity has no workflow set')
       
   298             raise validation_error(entity, {None: msg})
       
   299         for wf in mainwf.iter_workflows():
       
   300             if wf.state_by_eid(self.eidto):
       
   301                 break
       
   302         else:
       
   303             msg = _("state doesn't belong to entity's workflow. You may "
       
   304                     "want to set a custom workflow for this entity first.")
       
   305             raise validation_error(self.eidfrom, {('in_state', 'subject'): msg})
       
   306         if iworkflowable.current_workflow and wf.eid != iworkflowable.current_workflow.eid:
       
   307             msg = _("state doesn't belong to entity's current workflow")
       
   308             raise validation_error(self.eidfrom, {('in_state', 'subject'): msg})
       
   309 
       
   310 
       
   311 class SetModificationDateOnStateChange(WorkflowHook):
       
   312     """update entity's modification date after changing its state"""
       
   313     __regid__ = 'wfsyncmdate'
       
   314     __select__ = WorkflowHook.__select__ & hook.match_rtype('in_state')
       
   315     events = ('after_add_relation',)
       
   316 
       
   317     def __call__(self):
       
   318         if self._cw.added_in_transaction(self.eidfrom):
       
   319             # new entity, not needed
       
   320             return
       
   321         entity = self._cw.entity_from_eid(self.eidfrom)
       
   322         try:
       
   323             entity.cw_set(modification_date=datetime.utcnow())
       
   324         except RepositoryError as ex:
       
   325             # usually occurs if entity is coming from a read-only source
       
   326             # (eg ldap user)
       
   327             self.warning('cant change modification date for %s: %s', entity, ex)
       
   328 
       
   329 
       
   330 class CheckWorkflowTransitionExitPoint(WorkflowHook):
       
   331     """check that there is no multiple exits from the same state"""
       
   332     __regid__ = 'wfcheckwftrexit'
       
   333     __select__ = WorkflowHook.__select__ & hook.match_rtype('subworkflow_exit')
       
   334     events = ('after_add_relation',)
       
   335 
       
   336     def __call__(self):
       
   337         _CheckTrExitPoint(self._cw, treid=self.eidfrom)
       
   338 
       
   339 
       
   340 class SetCustomWorkflow(WorkflowHook):
       
   341     __regid__ = 'wfsetcustom'
       
   342     __select__ = WorkflowHook.__select__ & hook.match_rtype('custom_workflow')
       
   343     events = ('after_add_relation',)
       
   344 
       
   345     def __call__(self):
       
   346         _WorkflowChangedOp(self._cw, eid=self.eidfrom, wfeid=self.eidto)
       
   347 
       
   348 
       
   349 class DelCustomWorkflow(SetCustomWorkflow):
       
   350     __regid__ = 'wfdelcustom'
       
   351     events = ('after_delete_relation',)
       
   352 
       
   353     def __call__(self):
       
   354         entity = self._cw.entity_from_eid(self.eidfrom)
       
   355         typewf = entity.cw_adapt_to('IWorkflowable').cwetype_workflow()
       
   356         if typewf is not None:
       
   357             _WorkflowChangedOp(self._cw, eid=self.eidfrom, wfeid=typewf.eid)