|
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) |