#This file is part of Tryton. The COPYRIGHT file at the top level of #this repository contains the full copyright notices and license terms. from __future__ import with_statement import os import expr import base64 from trytond.model import ModelView, ModelSQL, fields from trytond.report import Report from trytond.tools import exec_command_pipe from trytond.backend import TableHandler from trytond.pyson import Eval, Equal, Not from trytond.transaction import Transaction class Workflow(ModelSQL, ModelView): "Workflow" _name = "workflow" _table = "wkf" _description = __doc__ name = fields.Char('Name', required=True, translate=True) model = fields.Char('Resource Model', required=True, select=1) on_create = fields.Boolean('On Create', select=2) activities = fields.One2Many('workflow.activity', 'workflow', 'Activities') def __init__(self): super(Workflow, self).__init__() self._error_messages.update({ 'no_workflow_defined': 'No workflow defined!', }) def init(self, module_name): super(Workflow, self).init(module_name) cursor = Transaction().cursor table = TableHandler(cursor, self, module_name) # Migration from 1.2 rename osv into model if table.column_exist('osv'): cursor.execute('UPDATE "' + self._table + '" ' \ 'SET model = osv') table.drop_column('osv', exception=True) def default_on_create(self): return 1 Workflow() class WorkflowActivity(ModelSQL, ModelView): "Workflow activity" _name = "workflow.activity" _table = "wkf_activity" _description = __doc__ name = fields.Char('Name', required=True, translate=True) workflow = fields.Many2One('workflow', 'Workflow', required=True, select=1, ondelete='CASCADE') split_mode = fields.Selection([ ('XOR', 'Xor'), ('OR', 'Or'), ('AND', 'And'), ], 'Split Mode', required=True) join_mode = fields.Selection([ ('XOR', 'Xor'), ('AND', 'And'), ], 'Join Mode', required=True) kind = fields.Selection([ ('dummy', 'Dummy'), ('function', 'Function'), ('subflow', 'Subflow'), ('stopall', 'Stop All'), ], 'Kind', required=True) action = fields.Text('Action', states={ 'readonly': Equal(Eval('kind'), 'dummy'), 'required': Equal(Eval('kind'), 'function'), }) flow_start = fields.Boolean('Flow Start') flow_stop = fields.Boolean('Flow Stop') subflow = fields.Many2One('workflow', 'Subflow', states={ 'readonly': Not(Equal(Eval('kind'), 'subflow')), 'required': Equal(Eval('kind'), 'subflow'), }) signal_send = fields.Char('Signal (subflow.*)') out_transitions = fields.One2Many('workflow.transition', 'act_from', 'Outgoing transitions') in_transitions = fields.One2Many('workflow.transition', 'act_to', 'Incoming transitions') #TODO add a _constraint on subflow without action #to have the same model than the workflow def default_kind(self): return 'dummy' def default_join_mode(self): return 'XOR' def default_split_mode(self): return 'XOR' def default_flow_start(self): return False def default_flow_stop(self): return False WorkflowActivity() class WorkflowTransition(ModelSQL, ModelView): "Workflow transition" _table = "wkf_transition" _name = "workflow.transition" _rec_name = 'signal' _description = __doc__ trigger_model = fields.Char('Trigger Type') trigger_expr_id = fields.Char('Trigger Expr ID') signal = fields.Char('Signal (button Name)') group = fields.Many2One('res.group', 'Group Required') condition = fields.Char('Condition', required=True) act_from = fields.Many2One('workflow.activity', 'Source Activity', required=True, select=1, ondelete='CASCADE') act_to = fields.Many2One('workflow.activity', 'Destination Activity', required=True, select=1, ondelete='CASCADE') instances = fields.Many2Many('workflow.transition-workflow.instance', 'trans_id', 'inst_id', 'Instances') def default_condition(self): return 'True' WorkflowTransition() class WorkflowInstance(ModelSQL, ModelView): "Workflow instance" _table = "wkf_instance" _name = "workflow.instance" _rec_name = 'res_type' _description = __doc__ workflow = fields.Many2One('workflow', 'Workflow', ondelete="RESTRICT", select=1) uid = fields.Integer('User ID') res_id = fields.Integer('Resource ID', required=True, select=1) res_type = fields.Char('Resource Model', required=True, select=1) state = fields.Char('State', required=True, select=1) overflows = fields.One2Many('workflow.workitem', 'subflow', 'Overflow') transitions = fields.Many2Many('workflow.transition-workflow.instance', 'inst_id', 'trans_id', 'Transitions') workitems = fields.One2Many('workflow.workitem', 'instance', 'Workitems') def __init__(self): super(WorkflowInstance, self).__init__() self._error_messages.update({ 'no_instance_defined': 'No workflow instance defined!', }) for i in ('create', 'write', 'delete', 'copy'): del self._rpc[i] #TODO add a constraint to have only one active instance by resource def init(self, module_name): super(WorkflowInstance, self).init(module_name) table = TableHandler(Transaction().cursor, self, module_name) table.index_action(['res_id', 'res_type', 'state'], 'add') table.index_action(['res_id', 'workflow'], 'add') def fields_get(self, fields_names=None): res = super(WorkflowInstance, self).fields_get( fields_names=fields_names) for field in res: res[field]['readonly'] = True return res def create(self, values): activity_obj = self.pool.get('workflow.activity') workitem_obj = self.pool.get('workflow.workitem') instance_id = super(WorkflowInstance, self).create(values) if 'workflow' in values: with Transaction().set_user(0): activity_ids = activity_obj.search([ ('flow_start', '=', True), ('workflow', '=', values['workflow']), ]) for activity_id in activity_ids: workitem_obj.create({ 'activity': activity_id, 'instance': instance_id, 'state': 'active', }) instance = self.browse(instance_id) self.update(instance) return instance_id def update(self, instance): ''' ''' workitem_obj = self.pool.get('workflow.workitem') for workitem in instance.workitems: workitem_obj.process(workitem) instance = self.browse(instance.id) return self._update_end(instance) def validate(self, instance, signal, force_running=False): ''' ''' workitem_obj = self.pool.get('workflow.workitem') for workitem in instance.workitems: workitem_obj.process(workitem, signal=signal, force_running=force_running) instance = self.browse(instance.id) return self._update_end(instance) def _update_end(self, instance): workitem_obj = self.pool.get('workflow.workitem') res = True for workitem in instance.workitems: if (workitem.state != 'complete') \ or not workitem.activity.flow_stop: res = False break if res: act_names = set() for workitem in instance.workitems: act_names.add(workitem.activity.name) with Transaction().set_user(0): self.write(instance.id, { 'state': 'complete', }) workitem_ids = workitem_obj.search([ ('subflow', '=', instance.id), ]) workitem_obj.write(workitem_ids, { 'state': 'complete', }) for workitem in workitem_obj.browse(workitem_ids): for act_name in act_names: self.validate(workitem.instance, signal='subflow.' + act_name) return res WorkflowInstance() class WorkflowTransitionInstance(ModelSQL): "Workflow Transition - Instance" _name = 'workflow.transition-workflow.instance' _table = 'wkf_witm_trans' _description = __doc__ trans_id = fields.Many2One('workflow.transition', 'Transition', ondelete='CASCADE', select=1, required=True) inst_id = fields.Many2One('workflow.instance', 'Instance', ondelete='CASCADE', select=1, required=True) def __init__(self): super(WorkflowTransitionInstance, self).__init__() for i in ('create', 'write', 'delete', 'copy'): del self._rpc[i] def fields_get(self, fields_names=None): res = super(WorkflowTransitionInstance, self).fields_get( fields_names=fields_names) for field in res: res[field]['readonly'] = True return res WorkflowTransitionInstance() class WorkflowWorkitem(ModelSQL, ModelView): "Workflow workitem" _table = "wkf_workitem" _name = "workflow.workitem" _rec_name = 'state' _description = __doc__ activity = fields.Many2One('workflow.activity', 'Activity', required=True, ondelete="CASCADE", select=1) subflow = fields.Many2One('workflow.instance', 'Subflow', ondelete="CASCADE", select=1) instance = fields.Many2One('workflow.instance', 'Instance', required=True, ondelete="CASCADE", select=1) state = fields.Char('State', select=1) def __init__(self): super(WorkflowWorkitem, self).__init__() for i in ('create', 'write', 'delete', 'copy'): del self._rpc[i] def fields_get(self, fields_names=None): res = super(WorkflowWorkitem, self).fields_get( fields_names=fields_names) for field in res: res[field]['readonly'] = True return res def create(self, values): workitem_id = super(WorkflowWorkitem, self).create(values) with Transaction().set_user(0): workitem = self.browse(workitem_id) self.process(workitem) return workitem_id def process(self, workitem, signal=None, force_running=False): ''' Process a workitem :param workitem: a BrowseRecord of the workflow.workitem :param signal: the signal :param force_running: a boolean ''' trigger_obj = self.pool.get('workflow.trigger') activity = workitem.activity triggers = False if workitem.state == 'active': triggers = True if not self._execute(workitem, activity): return False elif workitem.state == 'running': pass if workitem.state == 'complete' or force_running: res = self._split_test(workitem, activity.split_mode, signal) triggers = triggers and not res if triggers: for transition in activity.out_transitions: if transition.trigger_model: ids = expr.eval_expr( workitem.instance.res_type, workitem.instance.res_id, transition.trigger_expr_id) with Transaction().set_user(0): for res_id in ids: trigger_obj.create({ 'model': transition.trigger_model, 'res_id': res_id, 'instance': workitem.instance.id, 'workitem': workitem.id, }) return True def _state_set(self, workitem, state): self.write(workitem.id, { 'state': state, }) #XXX must be changed with a cache reset on BrowseRecord workitem._data[workitem.id]['state'] = state def _execute(self, workitem, activity): instance_obj = self.pool.get('workflow.instance') #send a signal to overflow if (workitem.state == 'active') and activity.signal_send: for overflow in workitem.overflows: instance_obj.validate(overflow.instance, activity.signal_send, force_running=True) if activity.kind == 'dummy': if workitem.state == 'active': self._state_set(workitem, 'complete') elif activity.kind == 'function': if workitem.state == 'active': self._state_set(workitem, 'running') expr.execute(workitem.instance.res_type, workitem.instance.res_id, activity) self._state_set(workitem, 'complete') elif activity.kind == 'stopall': if workitem.state == 'active': self._state_set(workitem, 'running') ids = self.search([ ('instance', '=', workitem.instance.id), ('id', '!=', workitem.id), ]) #XXX check if delete must not be replace by _state_set 'complete' with Transaction().set_user(0): self.delete(ids) if activity.action: expr.execute(workitem.instance.res_type, workitem.instance.res_id, activity) self._state_set(workitem, 'complete') elif activity.kind == 'subflow': if workitem.state == 'active': self._state_set(workitem, 'running') if activity.action: id_new = expr.execute(workitem.instance.res_type, workitem.instance.res_id, activity) if not id_new: with Transaction().set_user(0): self.delete(workitem.id) return False with Transaction().set_user(0): instance_id = instance_obj.search([ ('res_id', '=', id_new), ('workflow', '=', activity.subflow.id), ], limit=1)[0] else: instance_id = instance_obj.create({ 'res_type': workitem.instance.res_type, 'res_id': workitem.instance.res_id, 'workflow': activity.subflow.id, }) self.write(workitem.id, { 'subflow': instance_id, }) #XXX must be changed with a cache reset on BrowseRecord workitem._data[workitem.id]['subflow'] = instance_id elif workitem.state == 'running': if workitem.subflow.state == 'complete': self._state_set(workitem, 'complete') return True def _split_test(self, workitem, split_mode, signal=None): instance_obj = self.pool.get('workflow.instance') test = False transitions = [] if split_mode == 'XOR' or split_mode == 'OR': for transition in workitem.activity.out_transitions: if expr.check(workitem.instance.res_type, workitem.instance.res_id, transition, signal): test = True transitions.append(transition) if split_mode == 'XOR': break else: test = True for transition in workitem.activity.out_transitions: if not expr.check(workitem.instance.res_type, workitem.instance.res_id, transition, signal): test = False break if transition.id not in \ [x.id for x in workitem.instance.transitions]: transitions.append(transition) if test and len(transitions): with Transaction().set_user(0): instance_obj.write(workitem.instance.id, { 'transitions': [('add', [x.id for x in transitions])], }) instance = workitem.instance self.delete(workitem.id) for transition in transitions: self._join_test(transition, instance) return True return False def _join_test(self, transition, instance): instance_obj = self.pool.get('workflow.instance') activity = transition.act_to if activity.join_mode == 'XOR': self.create({ 'activity': activity.id, 'instance': instance.id, 'state': 'active', }) with Transaction().set_user(0): instance_obj.write(instance.id, { 'transitions': [('unlink', transition.id)], }) else: delete = True for transition in activity.in_transitions: if instance.id not in \ [x.id for x in transition.instances]: delete = False break if delete: with Transaction().set_user(0): instance_obj.write(instance.id, { 'transitions': [('unlink', [x.id for x in activity.in_transitions])], }) self.create({ 'activity': activity.id, 'instance': instance.id, 'state': 'active', }) WorkflowWorkitem() class WorkflowTrigger(ModelSQL, ModelView): "Workflow trigger" _table = "wkf_trigger" _name = "workflow.trigger" _description = __doc__ res_id = fields.Integer('Resource ID') model = fields.Char('Model') instance = fields.Many2One('workflow.instance', 'Destination Instance', ondelete="CASCADE") workitem = fields.Many2One('workflow.workitem', 'Workitem', required=True, ondelete="CASCADE") def __init__(self): super(WorkflowTrigger, self).__init__() for i in ('create', 'write', 'delete', 'copy'): del self._rpc[i] def init(self, module_name): super(WorkflowTrigger, self).init(module_name) table = TableHandler(Transaction().cursor, self, module_name) table.index_action(['res_id', 'model'], 'add') def fields_get(self, fields_names=None): res = super(WorkflowTrigger, self).fields_get( fields_names=fields_names) for field in res: res[field]['readonly'] = True return res WorkflowTrigger() class InstanceGraph(Report): _name = 'workflow.instance.graph' def execute(self, ids, datas): import pydot lang_obj = self.pool.get('ir.lang') workflow_obj = self.pool.get('workflow') instance_obj = self.pool.get('workflow.instance') lang_id = lang_obj.search([ ('code', '=', Transaction().language), ], limit=1)[0] lang = lang_obj.browse(lang_id) workflow_id = workflow_obj.search([ ('model', '=', datas['model']), ], limit=1) if not workflow_id: workflow_obj.raise_user_error('no_workflow_defined') workflow_id = workflow_id[0] workflow = workflow_obj.browse(workflow_id) instance_id = instance_obj.search([ ('res_id', '=', datas['id']), ('workflow', '=', workflow.id), ], order=[('id', 'DESC')], limit=1) if not instance_id: instance_obj.raise_user_error('no_instance_defined') instance_id = instance_id[0] title = "Workflow: %s" % (workflow.name.encode('ascii', 'replace'),) if pydot.__version__ == '1.0.2': # version 1.0.2 doesn't quote correctly label on Dot object title = '"' + title + '"' graph = pydot.Dot(fontsize='16', label=title) graph.set('center', '1') graph.set('ratio', 'auto') if lang.direction == 'ltr': if hasattr(graph, 'set_rankdir'): graph.set_rankdir('LR') else: graph.set('rankdir', 'LR') else: if hasattr(graph, 'set_rankdir'): graph.set_rankdir('RL') else: graph.set('rankdir', 'RL') self.graph_instance_get(graph, instance_id, datas.get('nested', False)) data = graph.create(prog='dot', format='png') return ('png', base64.encodestring(data), False, workflow.name) def graph_instance_get(self, graph, instance_id, nested=False): instance_obj = self.pool.get('workflow.instance') instance = instance_obj.browse(instance_id) self.graph_get(graph, instance.workflow.id, nested, self.workitem_get(instance.id)) def workitem_get(self, instance_id): res = {} workitem_obj = self.pool.get('workflow.workitem') workitem_ids = workitem_obj.search([ ('instance', '=', instance_id), ]) workitems = workitem_obj.browse(workitem_ids) for workitem in workitems: res.setdefault(workitem.activity.id, 0) res[workitem.activity.id] += 1 if workitem.subflow: res.update(self.workitem_get(workitem.subflow.id)) return res def graph_get(self, graph, workflow_id, nested=False, workitem=None): import pydot if workitem is None: workitem = {} activity_obj = self.pool.get('workflow.activity') workflow_obj = self.pool.get('workflow') transition_obj = self.pool.get('workflow.transition') activity_ids = activity_obj.search([ ('workflow', '=', workflow_id), ]) id2activities = {} actfrom = {} actto = {} activities = activity_obj.browse(activity_ids) start = 0 stop = {} for activity in activities: if activity.flow_start: start = activity.id if activity.flow_stop: stop['subflow.' + activity.name] = activity.id id2activities[activity.id] = activity if activity.subflow and nested: workflow = workflow_obj.browse(activity.subflow.id) subgraph = pydot.Cluster('subflow' + str(workflow.id), fontsize='12', label="Subflow: " + activity.name.encode('ascii', 'replace')) (substart, substop) = self.graph_get(subgraph, workflow.id, nested, workitem) graph.add_subgraph(subgraph) actfrom[activity.id] = substart actto[activity.id] = substop else: args = {} args['label'] = activity.name.encode('ascii', 'replace') args['fontsize'] = '10' if activity.flow_start or activity.flow_stop: args['style'] = 'filled' args['color'] = 'lightgrey' if activity.subflow: args['shape'] = 'box' else: args['shape'] = 'octagon' if activity.id in workitem: args['label'] += '\\nx ' + str(workitem[activity.id]) args['color'] = 'red' graph.add_node(pydot.Node(activity.id, **args)) actfrom[activity.id] = (activity.id, {}) actto[activity.id] = (activity.id, {}) transition_ids = transition_obj.search([ ('act_from', 'in', [x.id for x in activities]), ]) transitions = transition_obj.browse(transition_ids) for transition in transitions: args = {} args['label'] = ' ' if transition.condition != 'True': args['label'] += str(transition.condition).replace(' or ', '\\nor ').replace(' and ', '\\nand ') if transition.signal: args['label'] += '\\n' + str(transition.signal) args['style'] = 'bold' if id2activities[transition.act_from.id].split_mode == 'AND': args['arrowtail'] = 'box' elif id2activities[transition.act_from.id].split_mode == 'OR': args['arrowtail'] = 'inv' if id2activities[transition.act_to.id].join_mode == 'AND': args['arrowhead'] = 'crow' activity_from = actfrom[transition.act_from.id][1].get( transition.signal, actfrom[transition.act_from.id][0]) activity_to = actto[transition.act_to.id][1].get( transition.signal, actto[transition.act_to.id][0]) graph.add_edge(pydot.Edge(str(activity_from), str(activity_to), fontsize='10', **args)) return ((start, {}), (stop.values()[0], stop)) InstanceGraph()