server/hooks.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Mon, 08 Feb 2010 10:06:40 +0100
branchstable
changeset 4489 63128e8b9af9
parent 4212 ab6573088b4a
child 4496 14cbf2570ce8
permissions -rw-r--r--
fix security issue #36564 (integrity error w/ RQLUniqueConstraint) by using a global lock serializing commit when some RQLUniqueConstraint is encountered.

"""Core hooks: check schema validity, unsure we are not deleting necessary
entities...

:organization: Logilab
:copyright: 2001-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
"""
__docformat__ = "restructuredtext en"

from threading import Lock
from datetime import datetime

from cubicweb import UnknownProperty, ValidationError, BadConnectionId
from cubicweb.schema import RQLConstraint, RQLUniqueConstraint
from cubicweb.server.pool import Operation, LateOperation, PreCommitOperation
from cubicweb.server.hookhelper import (check_internal_entity,
                                        get_user_sessions, rproperty)
from cubicweb.server.repository import FTIndexEntityOp

# special relations that don't have to be checked for integrity, usually
# because they are handled internally by hooks (so we trust ourselves)
DONT_CHECK_RTYPES_ON_ADD = set(('owned_by', 'created_by',
                                'is', 'is_instance_of',
                                'wf_info_for', 'from_state', 'to_state'))
DONT_CHECK_RTYPES_ON_DEL = set(('is', 'is_instance_of',
                                'wf_info_for', 'from_state', 'to_state'))

_UNIQUE_CONSTRAINTS_LOCK = Lock()
_UNIQUE_CONSTRAINTS_HOLDER = None

class _ReleaseUniqueConstraintsHook(Operation):
    def commit_event(self):
        pass
    def postcommit_event(self):
        _release_unique_cstr_lock(self.session)
    def rollback_event(self):
        _release_unique_cstr_lock(self.session)

def _acquire_unique_cstr_lock(session):
    """acquire the _UNIQUE_CONSTRAINTS_LOCK for the session.

    This lock used to avoid potential integrity pb when checking
    RQLUniqueConstraint in two different transactions, as explained in
    http://intranet.logilab.fr/jpl/ticket/36564
    """
    global _UNIQUE_CONSTRAINTS_HOLDER
    asession = session.actual_session()
    if _UNIQUE_CONSTRAINTS_HOLDER is asession:
        return
    _UNIQUE_CONSTRAINTS_LOCK.acquire()
    _UNIQUE_CONSTRAINTS_HOLDER = asession
    # register operation responsible to release the lock on commit/rollback
    _ReleaseUniqueConstraintsHook(asession)

def _release_unique_cstr_lock(session):
    global _UNIQUE_CONSTRAINTS_HOLDER
    if _UNIQUE_CONSTRAINTS_HOLDER is session:
        _UNIQUE_CONSTRAINTS_HOLDER = None
        _UNIQUE_CONSTRAINTS_LOCK.release()
    else:
        assert _UNIQUE_CONSTRAINTS_HOLDER is None


def relation_deleted(session, eidfrom, rtype, eidto):
    session.transaction_data.setdefault('pendingrelations', []).append(
        (eidfrom, rtype, eidto))

def eschema_type_eid(session, etype):
    """get eid of the CWEType entity for the given yams type"""
    eschema = session.repo.schema.eschema(etype)
    # eschema.eid is None if schema has been readen from the filesystem, not
    # from the database (eg during tests)
    if eschema.eid is None:
        eschema.eid = session.unsafe_execute(
            'Any X WHERE X is CWEType, X name %(name)s',
            {'name': str(etype)})[0][0]
    return eschema.eid


# base meta-data handling ######################################################

def setctime_before_add_entity(session, entity):
    """before create a new entity -> set creation and modification date

    this is a conveniency hook, you shouldn't have to disable it
    """
    timestamp = datetime.now()
    entity.setdefault('creation_date', timestamp)
    entity.setdefault('modification_date', timestamp)
    if not session.get_shared_data('do-not-insert-cwuri'):
        entity.setdefault('cwuri', u'%seid/%s' % (session.base_url(), entity.eid))


def setmtime_before_update_entity(session, entity):
    """update an entity -> set modification date"""
    entity.setdefault('modification_date', datetime.now())


class SetCreatorOp(PreCommitOperation):

    def precommit_event(self):
        session = self.session
        if self.entity.eid in session.transaction_data.get('pendingeids', ()):
            # entity have been created and deleted in the same transaction
            return
        if not self.entity.created_by:
            session.add_relation(self.entity.eid, 'created_by', session.user.eid)


def setowner_after_add_entity(session, entity):
    """create a new entity -> set owner and creator metadata"""
    asession = session.actual_session()
    if not asession.is_internal_session:
        session.add_relation(entity.eid, 'owned_by', asession.user.eid)
        SetCreatorOp(asession, entity=entity)


def setis_after_add_entity(session, entity):
    """create a new entity -> set is relation"""
    if hasattr(entity, '_cw_recreating'):
        return
    try:
        #session.add_relation(entity.eid, 'is',
        #                     eschema_type_eid(session, entity.id))
        session.system_sql('INSERT INTO is_relation(eid_from,eid_to) VALUES (%s,%s)'
                           % (entity.eid, eschema_type_eid(session, entity.id)))
    except IndexError:
        # during schema serialization, skip
        return
    for etype in entity.e_schema.ancestors() + [entity.e_schema]:
        #session.add_relation(entity.eid, 'is_instance_of',
        #                     eschema_type_eid(session, etype))
        session.system_sql('INSERT INTO is_instance_of_relation(eid_from,eid_to) VALUES (%s,%s)'
                           % (entity.eid, eschema_type_eid(session, etype)))


def setowner_after_add_user(session, entity):
    """when a user has been created, add owned_by relation on itself"""
    session.add_relation(entity.eid, 'owned_by', entity.eid)


def fti_update_after_add_relation(session, eidfrom, rtype, eidto):
    """sync fulltext index when relevant relation is added. Reindexing the
    contained entity is enough since it will implicitly reindex the container
    entity.
    """
    ftcontainer = session.repo.schema.rschema(rtype).fulltext_container
    if ftcontainer == 'subject':
        FTIndexEntityOp(session, entity=session.entity_from_eid(eidto))
    elif ftcontainer == 'object':
        FTIndexEntityOp(session, entity=session.entity_from_eid(eidfrom))


def fti_update_after_delete_relation(session, eidfrom, rtype, eidto):
    """sync fulltext index when relevant relation is deleted. Reindexing both
    entities is necessary.
    """
    if session.repo.schema.rschema(rtype).fulltext_container:
        FTIndexEntityOp(session, entity=session.entity_from_eid(eidto))
        FTIndexEntityOp(session, entity=session.entity_from_eid(eidfrom))


class SyncOwnersOp(PreCommitOperation):

    def precommit_event(self):
        self.session.unsafe_execute('SET X owned_by U WHERE C owned_by U, C eid %(c)s,'
                                    'NOT EXISTS(X owned_by U, X eid %(x)s)',
                                    {'c': self.compositeeid, 'x': self.composedeid},
                                    ('c', 'x'))


def sync_owner_after_add_composite_relation(session, eidfrom, rtype, eidto):
    """when adding composite relation, the composed should have the same owners
    has the composite
    """
    if rtype == 'wf_info_for':
        # skip this special composite relation # XXX (syt) why?
        return
    composite = rproperty(session, rtype, eidfrom, eidto, 'composite')
    if composite == 'subject':
        SyncOwnersOp(session, compositeeid=eidfrom, composedeid=eidto)
    elif composite == 'object':
        SyncOwnersOp(session, compositeeid=eidto, composedeid=eidfrom)


def _register_metadata_hooks(hm):
    """register meta-data related hooks on the hooks manager"""
    hm.register_hook(setctime_before_add_entity, 'before_add_entity', '')
    hm.register_hook(setmtime_before_update_entity, 'before_update_entity', '')
    hm.register_hook(setowner_after_add_entity, 'after_add_entity', '')
    hm.register_hook(sync_owner_after_add_composite_relation, 'after_add_relation', '')
    hm.register_hook(fti_update_after_add_relation, 'after_add_relation', '')
    hm.register_hook(fti_update_after_delete_relation, 'after_delete_relation', '')
    if 'is' in hm.schema:
        hm.register_hook(setis_after_add_entity, 'after_add_entity', '')
    if 'CWUser' in hm.schema:
        hm.register_hook(setowner_after_add_user, 'after_add_entity', 'CWUser')


# core hooks ##################################################################

class DelayedDeleteOp(PreCommitOperation):
    """delete the object of composite relation except if the relation
    has actually been redirected to another composite
    """

    def precommit_event(self):
        session = self.session
        # don't do anything if the entity is being created or deleted
        if not (self.eid in session.transaction_data.get('pendingeids', ()) or
                self.eid in session.transaction_data.get('neweids', ())):
            etype = session.describe(self.eid)[0]
            if self.role == 'subject':
                rql = 'DELETE %s X WHERE X eid %%(x)s, NOT X %s Y'
            else: # self.role == 'object':
                rql = 'DELETE %s X WHERE X eid %%(x)s, NOT Y %s X'
            session.unsafe_execute(rql % (etype, self.rtype), {'x': self.eid}, 'x')


def handle_composite_before_del_relation(session, eidfrom, rtype, eidto):
    """delete the object of composite relation"""
    # if the relation is being delete, don't delete composite's components
    # automatically
    pendingrdefs = session.transaction_data.get('pendingrdefs', ())
    if (session.describe(eidfrom)[0], rtype, session.describe(eidto)[0]) in pendingrdefs:
        return
    composite = rproperty(session, rtype, eidfrom, eidto, 'composite')
    if composite == 'subject':
        DelayedDeleteOp(session, eid=eidto, rtype=rtype, role='object')
    elif composite == 'object':
        DelayedDeleteOp(session, eid=eidfrom, rtype=rtype, role='subject')


def before_del_group(session, eid):
    """check that we don't remove the owners group"""
    check_internal_entity(session, eid, ('owners',))


# schema validation hooks #####################################################

class CheckConstraintsOperation(LateOperation):
    """check a new relation satisfy its constraints
    """
    def precommit_event(self):
        eidfrom, rtype, eidto = self.rdef
        # first check related entities have not been deleted in the same
        # transaction
        pending = self.session.transaction_data.get('pendingeids', ())
        if eidfrom in pending:
            return
        if eidto in pending:
            return
        for constraint in self.constraints:
            # XXX
            # * lock RQLConstraint as well?
            # * use a constraint id to use per constraint lock and avoid
            #   unnecessary commit serialization ?
            if isinstance(constraint, RQLUniqueConstraint):
                _acquire_unique_cstr_lock(self.session)
            try:
                constraint.repo_check(self.session, eidfrom, rtype, eidto)
            except NotImplementedError:
                self.critical('can\'t check constraint %s, not supported',
                              constraint)

    def commit_event(self):
        pass


def cstrcheck_after_add_relation(session, eidfrom, rtype, eidto):
    """check the relation satisfy its constraints

    this is delayed to a precommit time operation since other relation which
    will make constraint satisfied may be added later.
    """
    if session.is_super_session:
        return
    constraints = rproperty(session, rtype, eidfrom, eidto, 'constraints')
    if constraints:
        # XXX get only RQL[Unique]Constraints?
        CheckConstraintsOperation(session, constraints=constraints,
                                  rdef=(eidfrom, rtype, eidto))

def uniquecstrcheck_before_modification(session, entity):
    if session.is_super_session:
        return
    eschema = entity.e_schema
    for attr in entity.edited_attributes:
        val = entity[attr]
        if val is None:
            continue
        if eschema.subjrels[attr].final and \
               eschema.has_unique_values(attr):
            rql = '%s X WHERE X %s %%(val)s' % (entity.e_schema, attr)
            rset = session.unsafe_execute(rql, {'val': val})
            if rset and rset[0][0] != entity.eid:
                msg = session._('the value "%s" is already used, use another one')
                raise ValidationError(entity.eid, {attr: msg % val})


def cstrcheck_after_update_attributes(session, entity):
    if session.is_super_session:
        return
    eschema = entity.e_schema
    for attr in entity.edited_attributes:
        if eschema.subjrels[attr].final:
            constraints = [c for c in entity.e_schema.constraints(attr)
                           if isinstance(c, (RQLConstraint, RQLUniqueConstraint))]
            if constraints:
                CheckConstraintsOperation(session, rdef=(entity.eid, attr, None),
                                          constraints=constraints)


class CheckRequiredRelationOperation(LateOperation):
    """checking relation cardinality has to be done after commit in
    case the relation is being replaced
    """
    eid, rtype = None, None

    def precommit_event(self):
        # recheck pending eids
        if self.eid in self.session.transaction_data.get('pendingeids', ()):
            return
        if self.rtype in self.session.transaction_data.get('pendingrtypes', ()):
            return
        if self.session.unsafe_execute(*self._rql()).rowcount < 1:
            etype = self.session.describe(self.eid)[0]
            _ = self.session._
            msg = _('at least one relation %(rtype)s is required on %(etype)s (%(eid)s)')
            msg %= {'rtype': _(self.rtype), 'etype': _(etype), 'eid': self.eid}
            raise ValidationError(self.eid, {self.rtype: msg})

    def commit_event(self):
        pass

    def _rql(self):
        raise NotImplementedError()


class CheckSRelationOp(CheckRequiredRelationOperation):
    """check required subject relation"""
    def _rql(self):
        return 'Any O WHERE S eid %%(x)s, S %s O' % self.rtype, {'x': self.eid}, 'x'


class CheckORelationOp(CheckRequiredRelationOperation):
    """check required object relation"""
    def _rql(self):
        return 'Any S WHERE O eid %%(x)s, S %s O' % self.rtype, {'x': self.eid}, 'x'


def checkrel_if_necessary(session, opcls, rtype, eid):
    """check an equivalent operation has not already been added"""
    for op in session.pending_operations:
        if isinstance(op, opcls) and op.rtype == rtype and op.eid == eid:
            break
    else:
        opcls(session, rtype=rtype, eid=eid)


def cardinalitycheck_after_add_entity(session, entity):
    """check cardinalities are satisfied"""
    if session.is_super_session:
        return
    eid = entity.eid
    for rschema, targetschemas, x in entity.e_schema.relation_definitions():
        # skip automatically handled relations
        if rschema.type in DONT_CHECK_RTYPES_ON_ADD:
            continue
        if x == 'subject':
            subjtype = entity.e_schema
            objtype = targetschemas[0].type
            cardindex = 0
            opcls = CheckSRelationOp
        else:
            subjtype = targetschemas[0].type
            objtype = entity.e_schema
            cardindex = 1
            opcls = CheckORelationOp
        card = rschema.rproperty(subjtype, objtype, 'cardinality')
        if card[cardindex] in '1+':
            checkrel_if_necessary(session, opcls, rschema.type, eid)

def cardinalitycheck_before_del_relation(session, eidfrom, rtype, eidto):
    """check cardinalities are satisfied"""
    if session.is_super_session:
        return
    if rtype in DONT_CHECK_RTYPES_ON_DEL:
        return
    card = rproperty(session, rtype, eidfrom, eidto, 'cardinality')
    pendingrdefs = session.transaction_data.get('pendingrdefs', ())
    if (session.describe(eidfrom)[0], rtype, session.describe(eidto)[0]) in pendingrdefs:
        return
    pendingeids = session.transaction_data.get('pendingeids', ())
    if card[0] in '1+' and not eidfrom in pendingeids:
        checkrel_if_necessary(session, CheckSRelationOp, rtype, eidfrom)
    if card[1] in '1+' and not eidto in pendingeids:
        checkrel_if_necessary(session, CheckORelationOp, rtype, eidto)


def _register_core_hooks(hm):
    hm.register_hook(handle_composite_before_del_relation, 'before_delete_relation', '')
    hm.register_hook(before_del_group, 'before_delete_entity', 'CWGroup')

    #hm.register_hook(cstrcheck_before_update_entity, 'before_update_entity', '')
    hm.register_hook(cardinalitycheck_after_add_entity, 'after_add_entity', '')
    hm.register_hook(cardinalitycheck_before_del_relation, 'before_delete_relation', '')
    hm.register_hook(cstrcheck_after_add_relation, 'after_add_relation', '')
    hm.register_hook(uniquecstrcheck_before_modification, 'before_add_entity', '')
    hm.register_hook(uniquecstrcheck_before_modification, 'before_update_entity', '')
    hm.register_hook(cstrcheck_after_update_attributes, 'after_add_entity', '')
    hm.register_hook(cstrcheck_after_update_attributes, 'after_update_entity', '')

# user/groups synchronisation #################################################

class GroupOperation(Operation):
    """base class for group operation"""
    geid = None
    def __init__(self, session, *args, **kwargs):
        """override to get the group name before actual groups manipulation:

        we may temporarily loose right access during a commit event, so
        no query should be emitted while comitting
        """
        rql = 'Any N WHERE G eid %(x)s, G name N'
        result = session.execute(rql, {'x': kwargs['geid']}, 'x', build_descr=False)
        Operation.__init__(self, session, *args, **kwargs)
        self.group = result[0][0]


class DeleteGroupOp(GroupOperation):
    """synchronize user when a in_group relation has been deleted"""
    def commit_event(self):
        """the observed connections pool has been commited"""
        groups = self.cnxuser.groups
        try:
            groups.remove(self.group)
        except KeyError:
            self.error('user %s not in group %s',  self.cnxuser, self.group)
            return


def after_del_in_group(session, fromeid, rtype, toeid):
    """modify user permission, need to update users"""
    for session_ in get_user_sessions(session.repo, fromeid):
        DeleteGroupOp(session, cnxuser=session_.user, geid=toeid)


class AddGroupOp(GroupOperation):
    """synchronize user when a in_group relation has been added"""
    def commit_event(self):
        """the observed connections pool has been commited"""
        groups = self.cnxuser.groups
        if self.group in groups:
            self.warning('user %s already in group %s', self.cnxuser,
                         self.group)
            return
        groups.add(self.group)


def after_add_in_group(session, fromeid, rtype, toeid):
    """modify user permission, need to update users"""
    for session_ in get_user_sessions(session.repo, fromeid):
        AddGroupOp(session, cnxuser=session_.user, geid=toeid)


class DelUserOp(Operation):
    """synchronize user when a in_group relation has been added"""
    def __init__(self, session, cnxid):
        self.cnxid = cnxid
        Operation.__init__(self, session)

    def commit_event(self):
        """the observed connections pool has been commited"""
        try:
            self.repo.close(self.cnxid)
        except BadConnectionId:
            pass # already closed


def after_del_user(session, eid):
    """modify user permission, need to update users"""
    for session_ in get_user_sessions(session.repo, eid):
        DelUserOp(session, session_.id)


def _register_usergroup_hooks(hm):
    """register user/group related hooks on the hooks manager"""
    hm.register_hook(after_del_user, 'after_delete_entity', 'CWUser')
    hm.register_hook(after_add_in_group, 'after_add_relation', 'in_group')
    hm.register_hook(after_del_in_group, 'after_delete_relation', 'in_group')


# workflow handling ###########################################################

from cubicweb.entities.wfobjs import WorkflowTransition, WorkflowException

def _change_state(session, x, oldstate, newstate):
    nocheck = session.transaction_data.setdefault('skip-security', set())
    nocheck.add((x, 'in_state', oldstate))
    nocheck.add((x, 'in_state', newstate))
    # delete previous state first in case we're using a super session
    fromsource = session.describe(x)[1]
    # don't try to remove previous state if in_state isn't stored in the system
    # source
    if fromsource == 'system' or \
       not session.repo.sources_by_uri[fromsource].support_relation('in_state'):
        session.delete_relation(x, 'in_state', oldstate)
    session.add_relation(x, 'in_state', newstate)


class FireAutotransitionOp(PreCommitOperation):
    """try to fire auto transition after state changes"""

    def precommit_event(self):
        session = self.session
        entity = self.entity
        autotrs = list(entity.possible_transitions('auto'))
        if autotrs:
            assert len(autotrs) == 1
            entity.fire_transition(autotrs[0])


def before_add_trinfo(session, entity):
    """check the transition is allowed, add missing information. Expect that:
    * wf_info_for inlined relation is set
    * by_transition or to_state (managers only) inlined relation is set
    """
    # first retreive entity to which the state change apply
    try:
        foreid = entity['wf_info_for']
    except KeyError:
        msg = session._('mandatory relation')
        raise ValidationError(entity.eid, {'wf_info_for': msg})
    forentity = session.entity_from_eid(foreid)
    # then check it has a workflow set, unless we're in the process of changing
    # entity's workflow
    if session.transaction_data.get((forentity.eid, 'customwf')):
        wfeid = session.transaction_data[(forentity.eid, 'customwf')]
        wf = session.entity_from_eid(wfeid)
    else:
        wf = forentity.current_workflow
    if wf is None:
        msg = session._('related entity has no workflow set')
        raise ValidationError(entity.eid, {None: msg})
    # then check it has a state set
    fromstate = forentity.current_state
    if fromstate is None:
        msg = session._('related entity has no state')
        raise ValidationError(entity.eid, {None: msg})
    # True if we are coming back from subworkflow
    swtr = session.transaction_data.pop((forentity.eid, 'subwfentrytr'), None)
    cowpowers = session.is_super_session or 'managers' in session.user.groups
    # no investigate the requested state change...
    try:
        treid = entity['by_transition']
    except KeyError:
        # no transition set, check user is a manager and destination state is
        # specified (and valid)
        if not cowpowers:
            msg = session._('mandatory relation')
            raise ValidationError(entity.eid, {'by_transition': msg})
        deststateeid = entity.get('to_state')
        if not deststateeid:
            msg = session._('mandatory relation')
            raise ValidationError(entity.eid, {'by_transition': msg})
        deststate = wf.state_by_eid(deststateeid)
        if deststate is None:
            msg = entity.req._("state doesn't belong to entity's current workflow")
            raise ValidationError(entity.eid, {'to_state': msg})
    else:
        # check transition is valid and allowed, unless we're coming back from
        # subworkflow
        tr = session.entity_from_eid(treid)
        if swtr is None:
            if tr is None:
                msg = session._("transition doesn't belong to entity's workflow")
                raise ValidationError(entity.eid, {'by_transition': msg})
            if not tr.has_input_state(fromstate):
                _ = session._
                msg = _("transition %(tr)s isn't allowed from %(st)s") % {'tr': _(tr.name),
                                                                          'st': _(fromstate.name),
                                                                          }
                raise ValidationError(entity.eid, {'by_transition': msg})
            if not tr.may_be_fired(foreid):
                msg = session._("transition may not be fired")
                raise ValidationError(entity.eid, {'by_transition': msg})
        if entity.get('to_state'):
            deststateeid = entity['to_state']
            if not cowpowers and deststateeid != tr.destination().eid:
                msg = session._("transition isn't allowed")
                raise ValidationError(entity.eid, {'by_transition': msg})
            if swtr is None:
                deststate = session.entity_from_eid(deststateeid)
                if not cowpowers and deststate is None:
                    msg = entity.req._("state doesn't belong to entity's workflow")
                    raise ValidationError(entity.eid, {'to_state': msg})
        else:
            deststateeid = tr.destination().eid
    # everything is ok, add missing information on the trinfo entity
    entity['from_state'] = fromstate.eid
    entity['to_state'] = deststateeid
    nocheck = session.transaction_data.setdefault('skip-security', set())
    nocheck.add((entity.eid, 'from_state', fromstate.eid))
    nocheck.add((entity.eid, 'to_state', deststateeid))
    FireAutotransitionOp(session, entity=forentity)


def after_add_trinfo(session, entity):
    """change related entity state"""
    _change_state(session, entity['wf_info_for'],
                  entity['from_state'], entity['to_state'])
    forentity = session.entity_from_eid(entity['wf_info_for'])
    assert forentity.current_state.eid == entity['to_state'], (
        forentity.eid, forentity.current_state.name)
    if forentity.main_workflow.eid != forentity.current_workflow.eid:
        SubWorkflowExitOp(session, forentity=forentity, trinfo=entity)

class SubWorkflowExitOp(PreCommitOperation):
    def precommit_event(self):
        session = self.session
        forentity = self.forentity
        trinfo = self.trinfo
        # we're in a subworkflow, check if we've reached an exit point
        wftr = forentity.subworkflow_input_transition()
        if wftr is None:
            # inconsistency detected
            msg = session._("state doesn't belong to entity's current workflow")
            raise ValidationError(self.trinfo.eid, {'to_state': msg})
        tostate = wftr.get_exit_point(forentity, trinfo['to_state'])
        if tostate is not None:
            # reached an exit point
            msg = session._('exiting from subworkflow %s')
            msg %= session._(forentity.current_workflow.name)
            session.transaction_data[(forentity.eid, 'subwfentrytr')] = True
            # XXX iirk
            req = forentity.req
            forentity.req = session.super_session
            try:
                trinfo = forentity.change_state(tostate, msg, u'text/plain',
                                                tr=wftr)
            finally:
                forentity.req = req


class SetInitialStateOp(PreCommitOperation):
    """make initial state be a default state"""

    def precommit_event(self):
        session = self.session
        entity = self.entity
        # if there is an initial state and the entity's state is not set,
        # use the initial state as a default state
        pendingeids = session.transaction_data.get('pendingeids', ())
        if not entity.eid in pendingeids and not entity.in_state and \
               entity.main_workflow:
            state = entity.main_workflow.initial
            if state:
                # use super session to by-pass security checks
                session.super_session.add_relation(entity.eid, 'in_state',
                                                   state.eid)


def set_initial_state_after_add(session, entity):
    SetInitialStateOp(session, entity=entity)


def before_add_in_state(session, eidfrom, rtype, eidto):
    """check state apply, in case of direct in_state change using unsafe_execute
    """
    nocheck = session.transaction_data.setdefault('skip-security', set())
    if (eidfrom, 'in_state', eidto) in nocheck:
        # state changed through TrInfo insertion, so we already know it's ok
        return
    entity = session.entity_from_eid(eidfrom)
    mainwf = entity.main_workflow
    if mainwf is None:
        msg = session._('entity has no workflow set')
        raise ValidationError(entity.eid, {None: msg})
    for wf in mainwf.iter_workflows():
        if wf.state_by_eid(eidto):
            break
    else:
        msg = session._("state doesn't belong to entity's workflow. You may "
                        "want to set a custom workflow for this entity first.")
        raise ValidationError(eidfrom, {'in_state': msg})
    if entity.current_workflow and wf.eid != entity.current_workflow.eid:
        msg = session._("state doesn't belong to entity's current workflow")
        raise ValidationError(eidfrom, {'in_state': msg})


class CheckTrExitPoint(PreCommitOperation):

    def precommit_event(self):
        tr = self.session.entity_from_eid(self.treid)
        outputs = set()
        for ep in tr.subworkflow_exit:
            if ep.subwf_state.eid in outputs:
                msg = self.session._("can't have multiple exits on the same state")
                raise ValidationError(self.treid, {'subworkflow_exit': msg})
            outputs.add(ep.subwf_state.eid)


def after_add_subworkflow_exit(session, eidfrom, rtype, eidto):
    CheckTrExitPoint(session, treid=eidfrom)


class WorkflowChangedOp(PreCommitOperation):
    """fix entity current state when changing its workflow"""

    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
        session = self.session
        pendingeids = session.transaction_data.get('pendingeids', ())
        if self.eid in pendingeids:
            return
        entity = session.entity_from_eid(self.eid)
        # check custom workflow has not been rechanged to another one in the same
        # transaction
        mainwf = entity.main_workflow
        if mainwf.eid == self.wfeid:
            deststate = mainwf.initial
            if not deststate:
                msg = session._('workflow has no initial state')
                raise ValidationError(entity.eid, {'custom_workflow': msg})
            if mainwf.state_by_eid(entity.current_state.eid):
                # nothing to do
                return
            # if there are no history, simply go to new workflow's initial state
            if not entity.workflow_history:
                if entity.current_state.eid != deststate.eid:
                    _change_state(session, entity.eid,
                                  entity.current_state.eid, deststate.eid)
                return
            msg = session._('workflow changed to "%s"')
            msg %= session._(mainwf.name)
            session.transaction_data[(entity.eid, 'customwf')] = self.wfeid
            entity.change_state(deststate, msg, u'text/plain')


def set_custom_workflow(session, eidfrom, rtype, eidto):
    WorkflowChangedOp(session, eid=eidfrom, wfeid=eidto)


def del_custom_workflow(session, eidfrom, rtype, eidto):
    entity = session.entity_from_eid(eidfrom)
    typewf = entity.cwetype_workflow()
    if typewf is not None:
        WorkflowChangedOp(session, eid=eidfrom, wfeid=typewf.eid)


def after_del_workflow(session, eid):
    # workflow cleanup
    session.execute('DELETE State X WHERE NOT X state_of Y')
    session.execute('DELETE Transition X WHERE NOT X transition_of Y')


def _register_wf_hooks(hm):
    """register workflow related hooks on the hooks manager"""
    if 'in_state' in hm.schema:
        hm.register_hook(before_add_trinfo, 'before_add_entity', 'TrInfo')
        hm.register_hook(after_add_trinfo, 'after_add_entity', 'TrInfo')
        #hm.register_hook(relation_deleted, 'before_delete_relation', 'in_state')
        for eschema in hm.schema.entities():
            if 'in_state' in eschema.subject_relations():
                hm.register_hook(set_initial_state_after_add, 'after_add_entity',
                                 str(eschema))
        hm.register_hook(set_custom_workflow, 'after_add_relation', 'custom_workflow')
        hm.register_hook(del_custom_workflow, 'after_delete_relation', 'custom_workflow')
        hm.register_hook(after_del_workflow, 'after_delete_entity', 'Workflow')
        hm.register_hook(before_add_in_state, 'before_add_relation', 'in_state')
        hm.register_hook(after_add_subworkflow_exit, 'after_add_relation', 'subworkflow_exit')


# CWProperty hooks #############################################################


class DelCWPropertyOp(Operation):
    """a user's custom properties has been deleted"""

    def commit_event(self):
        """the observed connections pool has been commited"""
        try:
            del self.epropdict[self.key]
        except KeyError:
            self.error('%s has no associated value', self.key)


class ChangeCWPropertyOp(Operation):
    """a user's custom properties has been added/changed"""

    def commit_event(self):
        """the observed connections pool has been commited"""
        self.epropdict[self.key] = self.value


class AddCWPropertyOp(Operation):
    """a user's custom properties has been added/changed"""

    def commit_event(self):
        """the observed connections pool has been commited"""
        eprop = self.eprop
        if not eprop.for_user:
            self.repo.vreg.eprop_values[eprop.pkey] = eprop.value
        # if for_user is set, update is handled by a ChangeCWPropertyOp operation


def after_add_eproperty(session, entity):
    key, value = entity.pkey, entity.value
    try:
        value = session.vreg.typed_value(key, value)
    except UnknownProperty:
        raise ValidationError(entity.eid, {'pkey': session._('unknown property key')})
    except ValueError, ex:
        raise ValidationError(entity.eid, {'value': session._(str(ex))})
    if not session.user.matching_groups('managers'):
        session.add_relation(entity.eid, 'for_user', session.user.eid)
    else:
        AddCWPropertyOp(session, eprop=entity)


def after_update_eproperty(session, entity):
    if not ('pkey' in entity.edited_attributes or
            'value' in entity.edited_attributes):
        return
    key, value = entity.pkey, entity.value
    try:
        value = session.vreg.typed_value(key, value)
    except UnknownProperty:
        return
    except ValueError, ex:
        raise ValidationError(entity.eid, {'value': session._(str(ex))})
    if entity.for_user:
        for session_ in get_user_sessions(session.repo, entity.for_user[0].eid):
            ChangeCWPropertyOp(session, epropdict=session_.user.properties,
                              key=key, value=value)
    else:
        # site wide properties
        ChangeCWPropertyOp(session, epropdict=session.vreg.eprop_values,
                          key=key, value=value)


def before_del_eproperty(session, eid):
    for eidfrom, rtype, eidto in session.transaction_data.get('pendingrelations', ()):
        if rtype == 'for_user' and eidfrom == eid:
            # if for_user was set, delete has already been handled
            break
    else:
        key = session.execute('Any K WHERE P eid %(x)s, P pkey K',
                              {'x': eid}, 'x')[0][0]
        DelCWPropertyOp(session, epropdict=session.vreg.eprop_values, key=key)


def after_add_for_user(session, fromeid, rtype, toeid):
    if not session.describe(fromeid)[0] == 'CWProperty':
        return
    key, value = session.execute('Any K,V WHERE P eid %(x)s,P pkey K,P value V',
                                 {'x': fromeid}, 'x')[0]
    if session.vreg.property_info(key)['sitewide']:
        raise ValidationError(fromeid,
                              {'for_user': session._("site-wide property can't be set for user")})
    for session_ in get_user_sessions(session.repo, toeid):
        ChangeCWPropertyOp(session, epropdict=session_.user.properties,
                          key=key, value=value)


def before_del_for_user(session, fromeid, rtype, toeid):
    key = session.execute('Any K WHERE P eid %(x)s, P pkey K',
                          {'x': fromeid}, 'x')[0][0]
    relation_deleted(session, fromeid, rtype, toeid)
    for session_ in get_user_sessions(session.repo, toeid):
        DelCWPropertyOp(session, epropdict=session_.user.properties, key=key)


def _register_eproperty_hooks(hm):
    """register workflow related hooks on the hooks manager"""
    hm.register_hook(after_add_eproperty, 'after_add_entity', 'CWProperty')
    hm.register_hook(after_update_eproperty, 'after_update_entity', 'CWProperty')
    hm.register_hook(before_del_eproperty, 'before_delete_entity', 'CWProperty')
    hm.register_hook(after_add_for_user, 'after_add_relation', 'for_user')
    hm.register_hook(before_del_for_user, 'before_delete_relation', 'for_user')