entities/wfobjs.py
changeset 5556 9ab2b4c74baf
parent 5426 0d4853a6e5ee
child 5557 1a534c596bff
equal deleted inserted replaced
5555:a64f48dd5fe4 5556:9ab2b4c74baf
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    14 # details.
    14 # details.
    15 #
    15 #
    16 # You should have received a copy of the GNU Lesser General Public License along
    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/>.
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    18 """workflow definition and history related entities
    18 """workflow handling:
    19 
    19 
       
    20 * entity types defining workflow (Workflow, State, Transition...)
       
    21 * workflow history (TrInfo)
       
    22 * adapter for workflowable entities (IWorkflowableAdapter)
    20 """
    23 """
       
    24 
    21 __docformat__ = "restructuredtext en"
    25 __docformat__ = "restructuredtext en"
    22 
    26 
    23 from warnings import warn
    27 from warnings import warn
    24 
    28 
    25 from logilab.common.decorators import cached, clear_cache
    29 from logilab.common.decorators import cached, clear_cache
    26 from logilab.common.deprecation import deprecated
    30 from logilab.common.deprecation import deprecated
    27 from logilab.common.compat import any
    31 from logilab.common.compat import any
    28 
    32 
    29 from cubicweb.entities import AnyEntity, fetch_config
    33 from cubicweb.entities import AnyEntity, fetch_config
    30 from cubicweb.interfaces import IWorkflowable
    34 from cubicweb.view import EntityAdapter
       
    35 from cubicweb.selectors import relation_possible
    31 from cubicweb.mixins import MI_REL_TRIGGERS
    36 from cubicweb.mixins import MI_REL_TRIGGERS
    32 
    37 
    33 class WorkflowException(Exception): pass
    38 class WorkflowException(Exception): pass
    34 
    39 
    35 class Workflow(AnyEntity):
    40 class Workflow(AnyEntity):
    44         """return True if this workflow is the default workflow for the given
    49         """return True if this workflow is the default workflow for the given
    45         entity type
    50         entity type
    46         """
    51         """
    47         return any(et for et in self.reverse_default_workflow
    52         return any(et for et in self.reverse_default_workflow
    48                    if et.name == etype)
    53                    if et.name == etype)
    49 
       
    50     # XXX define parent() instead? what if workflow of multiple types?
       
    51     def after_deletion_path(self):
       
    52         """return (path, parameters) which should be used as redirect
       
    53         information when this entity is being deleted
       
    54         """
       
    55         if self.workflow_of:
       
    56             return self.workflow_of[0].rest_path(), {'vid': 'workflow'}
       
    57         return super(Workflow, self).after_deletion_path()
       
    58 
    54 
    59     def iter_workflows(self, _done=None):
    55     def iter_workflows(self, _done=None):
    60         """return an iterator on actual workflows, eg this workflow and its
    56         """return an iterator on actual workflows, eg this workflow and its
    61         subworkflows
    57         subworkflows
    62         """
    58         """
   223                 if rqlexpr.check_expression(self._cw, eid):
   219                 if rqlexpr.check_expression(self._cw, eid):
   224                     return True
   220                     return True
   225         if self.condition or groups:
   221         if self.condition or groups:
   226             return False
   222             return False
   227         return True
   223         return True
   228 
       
   229     def after_deletion_path(self):
       
   230         """return (path, parameters) which should be used as redirect
       
   231         information when this entity is being deleted
       
   232         """
       
   233         if self.transition_of:
       
   234             return self.transition_of[0].rest_path(), {}
       
   235         return super(BaseTransition, self).after_deletion_path()
       
   236 
   224 
   237     def set_permissions(self, requiredgroups=(), conditions=(), reset=True):
   225     def set_permissions(self, requiredgroups=(), conditions=(), reset=True):
   238         """set or add (if `reset` is False) groups and conditions for this
   226         """set or add (if `reset` is False) groups and conditions for this
   239         transition
   227         transition
   240         """
   228         """
   275 
   263 
   276     def destination(self, entity):
   264     def destination(self, entity):
   277         try:
   265         try:
   278             return self.destination_state[0]
   266             return self.destination_state[0]
   279         except IndexError:
   267         except IndexError:
   280             return entity.latest_trinfo().previous_state
   268             return entity.cw_adapt_to('IWorkflowable').latest_trinfo().previous_state
   281 
   269 
   282     def potential_destinations(self):
   270     def potential_destinations(self):
   283         try:
   271         try:
   284             yield self.destination_state[0]
   272             yield self.destination_state[0]
   285         except IndexError:
   273         except IndexError:
   286             for incomingstate in self.reverse_allowed_transition:
   274             for incomingstate in self.reverse_allowed_transition:
   287                 for tr in incomingstate.reverse_destination_state:
   275                 for tr in incomingstate.reverse_destination_state:
   288                     for previousstate in tr.reverse_allowed_transition:
   276                     for previousstate in tr.reverse_allowed_transition:
   289                         yield previousstate
   277                         yield previousstate
   290 
       
   291     def parent(self):
       
   292         return self.workflow
       
   293 
   278 
   294 
   279 
   295 class WorkflowTransition(BaseTransition):
   280 class WorkflowTransition(BaseTransition):
   296     """customized class for WorkflowTransition entities"""
   281     """customized class for WorkflowTransition entities"""
   297     __regid__ = 'WorkflowTransition'
   282     __regid__ = 'WorkflowTransition'
   329             tostateeid = self.exit_points()[stateeid]
   314             tostateeid = self.exit_points()[stateeid]
   330         except KeyError:
   315         except KeyError:
   331             return None
   316             return None
   332         if tostateeid is None:
   317         if tostateeid is None:
   333             # go back to state from which we've entered the subworkflow
   318             # go back to state from which we've entered the subworkflow
   334             return entity.subworkflow_input_trinfo().previous_state
   319             return entity.cw_adapt_to('IWorkflowable').subworkflow_input_trinfo().previous_state
   335         return self._cw.entity_from_eid(tostateeid)
   320         return self._cw.entity_from_eid(tostateeid)
   336 
   321 
   337     @cached
   322     @cached
   338     def exit_points(self):
   323     def exit_points(self):
   339         result = {}
   324         result = {}
   356 
   341 
   357     @property
   342     @property
   358     def destination(self):
   343     def destination(self):
   359         return self.destination_state and self.destination_state[0] or None
   344         return self.destination_state and self.destination_state[0] or None
   360 
   345 
   361     def parent(self):
       
   362         return self.reverse_subworkflow_exit[0]
       
   363 
       
   364 
   346 
   365 class State(AnyEntity):
   347 class State(AnyEntity):
   366     """customized class for State entities"""
   348     """customized class for State entities"""
   367     __regid__ = 'State'
   349     __regid__ = 'State'
   368     fetch_attrs, fetch_order = fetch_config(['name'])
   350     fetch_attrs, fetch_order = fetch_config(['name'])
   369     rest_attr = 'eid'
   351     rest_attr = 'eid'
   370 
   352 
   371     @property
   353     @property
   372     def workflow(self):
   354     def workflow(self):
   373         # take care, may be missing in multi-sources configuration
   355         # take care, may be missing in multi-sources configuration
   374         return self.state_of and self.state_of[0]
   356         return self.state_of and self.state_of[0] or None
   375 
       
   376     def parent(self):
       
   377         return self.workflow
       
   378 
   357 
   379 
   358 
   380 class TrInfo(AnyEntity):
   359 class TrInfo(AnyEntity):
   381     """customized class for Transition information entities
   360     """customized class for Transition information entities
   382     """
   361     """
   397 
   376 
   398     @property
   377     @property
   399     def transition(self):
   378     def transition(self):
   400         return self.by_transition and self.by_transition[0] or None
   379         return self.by_transition and self.by_transition[0] or None
   401 
   380 
   402     def parent(self):
       
   403         return self.for_entity
       
   404 
       
   405 
   381 
   406 class WorkflowableMixIn(object):
   382 class WorkflowableMixIn(object):
   407     """base mixin providing workflow helper methods for workflowable entities.
   383     """base mixin providing workflow helper methods for workflowable entities.
   408     This mixin will be automatically set on class supporting the 'in_state'
   384     This mixin will be automatically set on class supporting the 'in_state'
   409     relation (which implies supporting 'wf_info_for' as well)
   385     relation (which implies supporting 'wf_info_for' as well)
   410     """
   386     """
   411     __implements__ = (IWorkflowable,)
   387 
       
   388     @property
       
   389     @deprecated('[3.5] use printable_state')
       
   390     def displayable_state(self):
       
   391         return self._cw._(self.state)
       
   392     @property
       
   393     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').main_workflow")
       
   394     def main_workflow(self):
       
   395         return self.cw_adapt_to('IWorkflowable').main_workflow
       
   396     @property
       
   397     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').current_workflow")
       
   398     def current_workflow(self):
       
   399         return self.cw_adapt_to('IWorkflowable').current_workflow
       
   400     @property
       
   401     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').current_state")
       
   402     def current_state(self):
       
   403         return self.cw_adapt_to('IWorkflowable').current_state
       
   404     @property
       
   405     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').state")
       
   406     def state(self):
       
   407         return self.cw_adapt_to('IWorkflowable').state
       
   408     @property
       
   409     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').printable_state")
       
   410     def printable_state(self):
       
   411         return self.cw_adapt_to('IWorkflowable').printable_state
       
   412     @property
       
   413     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').workflow_history")
       
   414     def workflow_history(self):
       
   415         return self.cw_adapt_to('IWorkflowable').workflow_history
       
   416 
       
   417     @deprecated('[3.5] get transition from current workflow and use its may_be_fired method')
       
   418     def can_pass_transition(self, trname):
       
   419         """return the Transition instance if the current user can fire the
       
   420         transition with the given name, else None
       
   421         """
       
   422         tr = self.current_workflow and self.current_workflow.transition_by_name(trname)
       
   423         if tr and tr.may_be_fired(self.eid):
       
   424             return tr
       
   425     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').cwetype_workflow()")
       
   426     def cwetype_workflow(self):
       
   427         return self.cw_adapt_to('IWorkflowable').main_workflow()
       
   428     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').latest_trinfo()")
       
   429     def latest_trinfo(self):
       
   430         return self.cw_adapt_to('IWorkflowable').latest_trinfo()
       
   431     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').possible_transitions()")
       
   432     def possible_transitions(self, type='normal'):
       
   433         return self.cw_adapt_to('IWorkflowable').possible_transitions(type)
       
   434     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').fire_transition()")
       
   435     def fire_transition(self, tr, comment=None, commentformat=None):
       
   436         return self.cw_adapt_to('IWorkflowable').fire_transition(tr, comment, commentformat)
       
   437     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').change_state()")
       
   438     def change_state(self, statename, comment=None, commentformat=None, tr=None):
       
   439         return self.cw_adapt_to('IWorkflowable').change_state(statename, comment, commentformat, tr)
       
   440     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').subworkflow_input_trinfo()")
       
   441     def subworkflow_input_trinfo(self):
       
   442         return self.cw_adapt_to('IWorkflowable').subworkflow_input_trinfo()
       
   443     @deprecated("[3.9] use entity.cw_adapt_to('IWorkflowable').subworkflow_input_transition()")
       
   444     def subworkflow_input_transition(self):
       
   445         return self.cw_adapt_to('IWorkflowable').subworkflow_input_transition()
       
   446 
       
   447 
       
   448 MI_REL_TRIGGERS[('in_state', 'subject')] = WorkflowableMixIn
       
   449 
       
   450 
       
   451 
       
   452 class IWorkflowableAdapter(WorkflowableMixIn, EntityAdapter):
       
   453     """base adapter providing workflow helper methods for workflowable entities.
       
   454     """
       
   455     __regid__ = 'IWorkflowable'
       
   456     __select__ = relation_possible('in_state')
       
   457 
       
   458     @cached
       
   459     def cwetype_workflow(self):
       
   460         """return the default workflow for entities of this type"""
       
   461         # XXX CWEType method
       
   462         wfrset = self._cw.execute('Any WF WHERE ET default_workflow WF, '
       
   463                                   'ET name %(et)s', {'et': self.entity.__regid__})
       
   464         if wfrset:
       
   465             return wfrset.get_entity(0, 0)
       
   466         self.warning("can't find any workflow for %s", self.entity.__regid__)
       
   467         return None
   412 
   468 
   413     @property
   469     @property
   414     def main_workflow(self):
   470     def main_workflow(self):
   415         """return current workflow applied to this entity"""
   471         """return current workflow applied to this entity"""
   416         if self.custom_workflow:
   472         if self.entity.custom_workflow:
   417             return self.custom_workflow[0]
   473             return self.entity.custom_workflow[0]
   418         return self.cwetype_workflow()
   474         return self.cwetype_workflow()
   419 
   475 
   420     @property
   476     @property
   421     def current_workflow(self):
   477     def current_workflow(self):
   422         """return current workflow applied to this entity"""
   478         """return current workflow applied to this entity"""
   423         return self.current_state and self.current_state.workflow or self.main_workflow
   479         return self.current_state and self.current_state.workflow or self.main_workflow
   424 
   480 
   425     @property
   481     @property
   426     def current_state(self):
   482     def current_state(self):
   427         """return current state entity"""
   483         """return current state entity"""
   428         return self.in_state and self.in_state[0] or None
   484         return self.entity.in_state and self.entity.in_state[0] or None
   429 
   485 
   430     @property
   486     @property
   431     def state(self):
   487     def state(self):
   432         """return current state name"""
   488         """return current state name"""
   433         try:
   489         try:
   434             return self.in_state[0].name
   490             return self.current_state.name
   435         except IndexError:
   491         except AttributeError:
   436             self.warning('entity %s has no state', self)
   492             self.warning('entity %s has no state', self)
   437             return None
   493             return None
   438 
   494 
   439     @property
   495     @property
   440     def printable_state(self):
   496     def printable_state(self):
   447     @property
   503     @property
   448     def workflow_history(self):
   504     def workflow_history(self):
   449         """return the workflow history for this entity (eg ordered list of
   505         """return the workflow history for this entity (eg ordered list of
   450         TrInfo entities)
   506         TrInfo entities)
   451         """
   507         """
   452         return self.reverse_wf_info_for
   508         return self.entity.reverse_wf_info_for
   453 
   509 
   454     def latest_trinfo(self):
   510     def latest_trinfo(self):
   455         """return the latest transition information for this entity"""
   511         """return the latest transition information for this entity"""
   456         try:
   512         try:
   457             return self.reverse_wf_info_for[-1]
   513             return self.workflow_history[-1]
   458         except IndexError:
   514         except IndexError:
   459             return None
   515             return None
   460 
       
   461     @cached
       
   462     def cwetype_workflow(self):
       
   463         """return the default workflow for entities of this type"""
       
   464         # XXX CWEType method
       
   465         wfrset = self._cw.execute('Any WF WHERE ET default_workflow WF, '
       
   466                                   'ET name %(et)s', {'et': self.__regid__})
       
   467         if wfrset:
       
   468             return wfrset.get_entity(0, 0)
       
   469         self.warning("can't find any workflow for %s", self.__regid__)
       
   470         return None
       
   471 
   516 
   472     def possible_transitions(self, type='normal'):
   517     def possible_transitions(self, type='normal'):
   473         """generates transition that MAY be fired for the given entity,
   518         """generates transition that MAY be fired for the given entity,
   474         expected to be in this state
   519         expected to be in this state
   475         used only by the UI
   520         used only by the UI
   481             'T type TT, T type %(type)s, '
   526             'T type TT, T type %(type)s, '
   482             'T name TN, T transition_of WF, WF eid %(wfeid)s',
   527             'T name TN, T transition_of WF, WF eid %(wfeid)s',
   483             {'x': self.current_state.eid, 'type': type,
   528             {'x': self.current_state.eid, 'type': type,
   484              'wfeid': self.current_workflow.eid})
   529              'wfeid': self.current_workflow.eid})
   485         for tr in rset.entities():
   530         for tr in rset.entities():
   486             if tr.may_be_fired(self.eid):
   531             if tr.may_be_fired(self.entity.eid):
   487                 yield tr
   532                 yield tr
   488 
       
   489     def _add_trinfo(self, comment, commentformat, treid=None, tseid=None):
       
   490         kwargs = {}
       
   491         if comment is not None:
       
   492             kwargs['comment'] = comment
       
   493             if commentformat is not None:
       
   494                 kwargs['comment_format'] = commentformat
       
   495         kwargs['wf_info_for'] = self
       
   496         if treid is not None:
       
   497             kwargs['by_transition'] = self._cw.entity_from_eid(treid)
       
   498         if tseid is not None:
       
   499             kwargs['to_state'] = self._cw.entity_from_eid(tseid)
       
   500         return self._cw.create_entity('TrInfo', **kwargs)
       
   501 
       
   502     def fire_transition(self, tr, comment=None, commentformat=None):
       
   503         """change the entity's state by firing transition of the given name in
       
   504         entity's workflow
       
   505         """
       
   506         assert self.current_workflow
       
   507         if isinstance(tr, basestring):
       
   508             _tr = self.current_workflow.transition_by_name(tr)
       
   509             assert _tr is not None, 'not a %s transition: %s' % (
       
   510                 self.__regid__, tr)
       
   511             tr = _tr
       
   512         return self._add_trinfo(comment, commentformat, tr.eid)
       
   513 
       
   514     def change_state(self, statename, comment=None, commentformat=None, tr=None):
       
   515         """change the entity's state to the given state (name or entity) in
       
   516         entity's workflow. This method should only by used by manager to fix an
       
   517         entity's state when their is no matching transition, otherwise
       
   518         fire_transition should be used.
       
   519         """
       
   520         assert self.current_workflow
       
   521         if hasattr(statename, 'eid'):
       
   522             stateeid = statename.eid
       
   523         else:
       
   524             if not isinstance(statename, basestring):
       
   525                 warn('[3.5] give a state name', DeprecationWarning)
       
   526                 state = self.current_workflow.state_by_eid(statename)
       
   527             else:
       
   528                 state = self.current_workflow.state_by_name(statename)
       
   529             if state is None:
       
   530                 raise WorkflowException('not a %s state: %s' % (self.__regid__,
       
   531                                                                 statename))
       
   532             stateeid = state.eid
       
   533         # XXX try to find matching transition?
       
   534         return self._add_trinfo(comment, commentformat, tr and tr.eid, stateeid)
       
   535 
   533 
   536     def subworkflow_input_trinfo(self):
   534     def subworkflow_input_trinfo(self):
   537         """return the TrInfo which has be recorded when this entity went into
   535         """return the TrInfo which has be recorded when this entity went into
   538         the current sub-workflow
   536         the current sub-workflow
   539         """
   537         """
   559     def subworkflow_input_transition(self):
   557     def subworkflow_input_transition(self):
   560         """return the transition which has went through the current sub-workflow
   558         """return the transition which has went through the current sub-workflow
   561         """
   559         """
   562         return getattr(self.subworkflow_input_trinfo(), 'transition', None)
   560         return getattr(self.subworkflow_input_trinfo(), 'transition', None)
   563 
   561 
   564     def clear_all_caches(self):
   562     def _add_trinfo(self, comment, commentformat, treid=None, tseid=None):
   565         super(WorkflowableMixIn, self).clear_all_caches()
   563         kwargs = {}
   566         clear_cache(self, 'cwetype_workflow')
   564         if comment is not None:
   567 
   565             kwargs['comment'] = comment
   568     @deprecated('[3.5] get transition from current workflow and use its may_be_fired method')
   566             if commentformat is not None:
   569     def can_pass_transition(self, trname):
   567                 kwargs['comment_format'] = commentformat
   570         """return the Transition instance if the current user can fire the
   568         kwargs['wf_info_for'] = self.entity
   571         transition with the given name, else None
   569         if treid is not None:
   572         """
   570             kwargs['by_transition'] = self._cw.entity_from_eid(treid)
   573         tr = self.current_workflow and self.current_workflow.transition_by_name(trname)
   571         if tseid is not None:
   574         if tr and tr.may_be_fired(self.eid):
   572             kwargs['to_state'] = self._cw.entity_from_eid(tseid)
   575             return tr
   573         return self._cw.create_entity('TrInfo', **kwargs)
   576 
   574 
   577     @property
   575     def fire_transition(self, tr, comment=None, commentformat=None):
   578     @deprecated('[3.5] use printable_state')
   576         """change the entity's state by firing transition of the given name in
   579     def displayable_state(self):
   577         entity's workflow
   580         return self._cw._(self.state)
   578         """
   581 
   579         assert self.current_workflow
   582 MI_REL_TRIGGERS[('in_state', 'subject')] = WorkflowableMixIn
   580         if isinstance(tr, basestring):
       
   581             _tr = self.current_workflow.transition_by_name(tr)
       
   582             assert _tr is not None, 'not a %s transition: %s' % (
       
   583                 self.__regid__, tr)
       
   584             tr = _tr
       
   585         return self._add_trinfo(comment, commentformat, tr.eid)
       
   586 
       
   587     def change_state(self, statename, comment=None, commentformat=None, tr=None):
       
   588         """change the entity's state to the given state (name or entity) in
       
   589         entity's workflow. This method should only by used by manager to fix an
       
   590         entity's state when their is no matching transition, otherwise
       
   591         fire_transition should be used.
       
   592         """
       
   593         assert self.current_workflow
       
   594         if hasattr(statename, 'eid'):
       
   595             stateeid = statename.eid
       
   596         else:
       
   597             if not isinstance(statename, basestring):
       
   598                 warn('[3.5] give a state name', DeprecationWarning)
       
   599                 state = self.current_workflow.state_by_eid(statename)
       
   600             else:
       
   601                 state = self.current_workflow.state_by_name(statename)
       
   602             if state is None:
       
   603                 raise WorkflowException('not a %s state: %s' % (self.__regid__,
       
   604                                                                 statename))
       
   605             stateeid = state.eid
       
   606         # XXX try to find matching transition?
       
   607         return self._add_trinfo(comment, commentformat, tr and tr.eid, stateeid)