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): |
175 execute('SET X in_state S WHERE S eid %(s)s', {'s': todelstate.eid}) |
171 execute('SET X in_state S WHERE S eid %(s)s', {'s': todelstate.eid}) |
176 execute('SET X from_state NS WHERE X to_state OS, OS eid %(os)s, NS eid %(ns)s', |
172 execute('SET X from_state NS WHERE X to_state OS, OS eid %(os)s, NS eid %(ns)s', |
177 {'os': todelstate.eid, 'ns': replacement.eid}) |
173 {'os': todelstate.eid, 'ns': replacement.eid}) |
178 execute('SET X to_state NS WHERE X to_state OS, OS eid %(os)s, NS eid %(ns)s', |
174 execute('SET X to_state NS WHERE X to_state OS, OS eid %(os)s, NS eid %(ns)s', |
179 {'os': todelstate.eid, 'ns': replacement.eid}) |
175 {'os': todelstate.eid, 'ns': replacement.eid}) |
180 todelstate.delete() |
176 todelstate.cw_delete() |
181 |
177 |
182 |
178 |
183 class BaseTransition(AnyEntity): |
179 class BaseTransition(AnyEntity): |
184 """customized class for abstract transition |
180 """customized class for abstract transition |
185 |
181 |
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) |