[edit controller] fix handling of removal of subentities from an edit form
In CubicWeb 3.18 the semantics of dropping a composite relation were changed.
It had before the effect of deleting the subentities.
Now the edit controller must handle this by itself (it was not adapted then by
mistake).
So if it detects the removal of a composite relation (on the browser side, the
user clicked on the "remove" action), the subentity is now explicitly deleted.
It was chosen not to change the form generation but the EditController:
- to ensure pre-3.18 forms backward compat
- to avoid the introduction of a specific entity-deletion API (RQL is probably
our best API for complex DB operations)
Related to #8529868.
# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb. If not, see <http://www.gnu.org/licenses/>.
"""Core hooks: workflow related hooks"""
__docformat__ = "restructuredtext en"
_ = unicode
from datetime import datetime
from cubicweb import RepositoryError, validation_error
from cubicweb.predicates import is_instance, adaptable
from cubicweb.server import hook
def _change_state(cnx, x, oldstate, newstate):
nocheck = cnx.transaction_data.setdefault('skip-security', set())
nocheck.add((x, 'in_state', oldstate))
nocheck.add((x, 'in_state', newstate))
# delete previous state first
cnx.delete_relation(x, 'in_state', oldstate)
cnx.add_relation(x, 'in_state', newstate)
# operations ###################################################################
class _SetInitialStateOp(hook.Operation):
"""make initial state be a default state"""
entity = None # make pylint happy
def precommit_event(self):
cnx = self.cnx
entity = self.entity
iworkflowable = entity.cw_adapt_to('IWorkflowable')
# if there is an initial state and the entity's state is not set,
# use the initial state as a default state
if not (cnx.deleted_in_transaction(entity.eid) or entity.in_state) \
and iworkflowable.current_workflow:
state = iworkflowable.current_workflow.initial
if state:
cnx.add_relation(entity.eid, 'in_state', state.eid)
_FireAutotransitionOp(cnx, entity=entity)
class _FireAutotransitionOp(hook.Operation):
"""try to fire auto transition after state changes"""
entity = None # make pylint happy
def precommit_event(self):
entity = self.entity
iworkflowable = entity.cw_adapt_to('IWorkflowable')
autotrs = list(iworkflowable.possible_transitions('auto'))
if autotrs:
assert len(autotrs) == 1
iworkflowable.fire_transition(autotrs[0])
class _WorkflowChangedOp(hook.Operation):
"""fix entity current state when changing its workflow"""
eid = wfeid = None # make pylint happy
def precommit_event(self):
# notice that enforcement that new workflow apply to the entity's type is
# done by schema rule, no need to check it here
cnx = self.cnx
pendingeids = cnx.transaction_data.get('pendingeids', ())
if self.eid in pendingeids:
return
entity = cnx.entity_from_eid(self.eid)
iworkflowable = entity.cw_adapt_to('IWorkflowable')
# check custom workflow has not been rechanged to another one in the same
# transaction
mainwf = iworkflowable.main_workflow
if mainwf.eid == self.wfeid:
deststate = mainwf.initial
if not deststate:
msg = _('workflow has no initial state')
raise validation_error(entity, {('custom_workflow', 'subject'): msg})
if mainwf.state_by_eid(iworkflowable.current_state.eid):
# nothing to do
return
# if there are no history, simply go to new workflow's initial state
if not iworkflowable.workflow_history:
if iworkflowable.current_state.eid != deststate.eid:
_change_state(cnx, entity.eid,
iworkflowable.current_state.eid, deststate.eid)
_FireAutotransitionOp(cnx, entity=entity)
return
msg = cnx._('workflow changed to "%s"')
msg %= cnx._(mainwf.name)
cnx.transaction_data[(entity.eid, 'customwf')] = self.wfeid
iworkflowable.change_state(deststate, msg, u'text/plain')
class _CheckTrExitPoint(hook.Operation):
treid = None # make pylint happy
def precommit_event(self):
tr = self.cnx.entity_from_eid(self.treid)
outputs = set()
for ep in tr.subworkflow_exit:
if ep.subwf_state.eid in outputs:
msg = _("can't have multiple exits on the same state")
raise validation_error(self.treid, {('subworkflow_exit', 'subject'): msg})
outputs.add(ep.subwf_state.eid)
class _SubWorkflowExitOp(hook.Operation):
forentity = trinfo = None # make pylint happy
def precommit_event(self):
cnx = self.cnx
forentity = self.forentity
iworkflowable = forentity.cw_adapt_to('IWorkflowable')
trinfo = self.trinfo
# we're in a subworkflow, check if we've reached an exit point
wftr = iworkflowable.subworkflow_input_transition()
if wftr is None:
# inconsistency detected
msg = _("state doesn't belong to entity's current workflow")
raise validation_error(self.trinfo, {('to_state', 'subject'): msg})
tostate = wftr.get_exit_point(forentity, trinfo.cw_attr_cache['to_state'])
if tostate is not None:
# reached an exit point
msg = _('exiting from subworkflow %s')
msg %= cnx._(iworkflowable.current_workflow.name)
cnx.transaction_data[(forentity.eid, 'subwfentrytr')] = True
iworkflowable.change_state(tostate, msg, u'text/plain', tr=wftr)
# hooks ########################################################################
class WorkflowHook(hook.Hook):
__abstract__ = True
category = 'metadata'
class SetInitialStateHook(WorkflowHook):
__regid__ = 'wfsetinitial'
__select__ = WorkflowHook.__select__ & adaptable('IWorkflowable')
events = ('after_add_entity',)
def __call__(self):
_SetInitialStateOp(self._cw, entity=self.entity)
class FireTransitionHook(WorkflowHook):
"""check the transition is allowed and add missing information into the
TrInfo entity.
Expect that:
* wf_info_for inlined relation is set
* by_transition or to_state (managers only) inlined relation is set
Check for automatic transition to be fired at the end
"""
__regid__ = 'wffiretransition'
__select__ = WorkflowHook.__select__ & is_instance('TrInfo')
events = ('before_add_entity',)
def __call__(self):
cnx = self._cw
entity = self.entity
# first retreive entity to which the state change apply
try:
foreid = entity.cw_attr_cache['wf_info_for']
except KeyError:
msg = _('mandatory relation')
raise validation_error(entity, {('wf_info_for', 'subject'): msg})
forentity = cnx.entity_from_eid(foreid)
# see comment in the TrInfo entity definition
entity.cw_edited['tr_count']=len(forentity.reverse_wf_info_for)
iworkflowable = forentity.cw_adapt_to('IWorkflowable')
# then check it has a workflow set, unless we're in the process of changing
# entity's workflow
if cnx.transaction_data.get((forentity.eid, 'customwf')):
wfeid = cnx.transaction_data[(forentity.eid, 'customwf')]
wf = cnx.entity_from_eid(wfeid)
else:
wf = iworkflowable.current_workflow
if wf is None:
msg = _('related entity has no workflow set')
raise validation_error(entity, {None: msg})
# then check it has a state set
fromstate = iworkflowable.current_state
if fromstate is None:
msg = _('related entity has no state')
raise validation_error(entity, {None: msg})
# True if we are coming back from subworkflow
swtr = cnx.transaction_data.pop((forentity.eid, 'subwfentrytr'), None)
cowpowers = (cnx.user.is_in_group('managers')
or not cnx.write_security)
# no investigate the requested state change...
try:
treid = entity.cw_attr_cache['by_transition']
except KeyError:
# no transition set, check user is a manager and destination state
# is specified (and valid)
if not cowpowers:
msg = _('mandatory relation')
raise validation_error(entity, {('by_transition', 'subject'): msg})
deststateeid = entity.cw_attr_cache.get('to_state')
if not deststateeid:
msg = _('mandatory relation')
raise validation_error(entity, {('by_transition', 'subject'): msg})
deststate = wf.state_by_eid(deststateeid)
if deststate is None:
msg = _("state doesn't belong to entity's workflow")
raise validation_error(entity, {('to_state', 'subject'): msg})
else:
# check transition is valid and allowed, unless we're coming back
# from subworkflow
tr = cnx.entity_from_eid(treid)
if swtr is None:
qname = ('by_transition', 'subject')
if tr is None:
msg = _("transition doesn't belong to entity's workflow")
raise validation_error(entity, {qname: msg})
if not tr.has_input_state(fromstate):
msg = _("transition %(tr)s isn't allowed from %(st)s")
raise validation_error(entity, {qname: msg}, {
'tr': tr.name, 'st': fromstate.name}, ['tr', 'st'])
if not tr.may_be_fired(foreid):
msg = _("transition may not be fired")
raise validation_error(entity, {qname: msg})
deststateeid = entity.cw_attr_cache.get('to_state')
if deststateeid is not None:
if not cowpowers and deststateeid != tr.destination(forentity).eid:
msg = _("transition isn't allowed")
raise validation_error(entity, {('by_transition', 'subject'): msg})
if swtr is None:
deststate = cnx.entity_from_eid(deststateeid)
if not cowpowers and deststate is None:
msg = _("state doesn't belong to entity's workflow")
raise validation_error(entity, {('to_state', 'subject'): msg})
else:
deststateeid = tr.destination(forentity).eid
# everything is ok, add missing information on the trinfo entity
entity.cw_edited['from_state'] = fromstate.eid
entity.cw_edited['to_state'] = deststateeid
nocheck = cnx.transaction_data.setdefault('skip-security', set())
nocheck.add((entity.eid, 'from_state', fromstate.eid))
nocheck.add((entity.eid, 'to_state', deststateeid))
_FireAutotransitionOp(cnx, entity=forentity)
class FiredTransitionHook(WorkflowHook):
"""change related entity state and handle exit of subworkflow"""
__regid__ = 'wffiretransition'
__select__ = WorkflowHook.__select__ & is_instance('TrInfo')
events = ('after_add_entity',)
def __call__(self):
trinfo = self.entity
rcache = trinfo.cw_attr_cache
_change_state(self._cw, rcache['wf_info_for'], rcache['from_state'],
rcache['to_state'])
forentity = self._cw.entity_from_eid(rcache['wf_info_for'])
iworkflowable = forentity.cw_adapt_to('IWorkflowable')
assert iworkflowable.current_state.eid == rcache['to_state']
if iworkflowable.main_workflow.eid != iworkflowable.current_workflow.eid:
_SubWorkflowExitOp(self._cw, forentity=forentity, trinfo=trinfo)
class CheckInStateChangeAllowed(WorkflowHook):
"""check state apply, in case of direct in_state change using unsafe execute
"""
__regid__ = 'wfcheckinstate'
__select__ = WorkflowHook.__select__ & hook.match_rtype('in_state')
events = ('before_add_relation',)
category = 'integrity'
def __call__(self):
cnx = self._cw
nocheck = cnx.transaction_data.get('skip-security', ())
if (self.eidfrom, 'in_state', self.eidto) in nocheck:
# state changed through TrInfo insertion, so we already know it's ok
return
entity = cnx.entity_from_eid(self.eidfrom)
iworkflowable = entity.cw_adapt_to('IWorkflowable')
mainwf = iworkflowable.main_workflow
if mainwf is None:
msg = _('entity has no workflow set')
raise validation_error(entity, {None: msg})
for wf in mainwf.iter_workflows():
if wf.state_by_eid(self.eidto):
break
else:
msg = _("state doesn't belong to entity's workflow. You may "
"want to set a custom workflow for this entity first.")
raise validation_error(self.eidfrom, {('in_state', 'subject'): msg})
if iworkflowable.current_workflow and wf.eid != iworkflowable.current_workflow.eid:
msg = _("state doesn't belong to entity's current workflow")
raise validation_error(self.eidfrom, {('in_state', 'subject'): msg})
class SetModificationDateOnStateChange(WorkflowHook):
"""update entity's modification date after changing its state"""
__regid__ = 'wfsyncmdate'
__select__ = WorkflowHook.__select__ & hook.match_rtype('in_state')
events = ('after_add_relation',)
def __call__(self):
if self._cw.added_in_transaction(self.eidfrom):
# new entity, not needed
return
entity = self._cw.entity_from_eid(self.eidfrom)
try:
entity.cw_set(modification_date=datetime.now())
except RepositoryError as ex:
# usually occurs if entity is coming from a read-only source
# (eg ldap user)
self.warning('cant change modification date for %s: %s', entity, ex)
class CheckWorkflowTransitionExitPoint(WorkflowHook):
"""check that there is no multiple exits from the same state"""
__regid__ = 'wfcheckwftrexit'
__select__ = WorkflowHook.__select__ & hook.match_rtype('subworkflow_exit')
events = ('after_add_relation',)
def __call__(self):
_CheckTrExitPoint(self._cw, treid=self.eidfrom)
class SetCustomWorkflow(WorkflowHook):
__regid__ = 'wfsetcustom'
__select__ = WorkflowHook.__select__ & hook.match_rtype('custom_workflow')
events = ('after_add_relation',)
def __call__(self):
_WorkflowChangedOp(self._cw, eid=self.eidfrom, wfeid=self.eidto)
class DelCustomWorkflow(SetCustomWorkflow):
__regid__ = 'wfdelcustom'
events = ('after_delete_relation',)
def __call__(self):
entity = self._cw.entity_from_eid(self.eidfrom)
typewf = entity.cw_adapt_to('IWorkflowable').cwetype_workflow()
if typewf is not None:
_WorkflowChangedOp(self._cw, eid=self.eidfrom, wfeid=typewf.eid)