server/hooks.py
author sylvain.thenault@logilab.fr
Fri, 09 Jan 2009 15:29:51 +0100
changeset 380 06e7f2932afe
parent 62 ef06f71533d9
child 479 ac5c9442b1fd
permissions -rw-r--r--
db-check fixes

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

:organization: Logilab
:copyright: 2001-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
"""
__docformat__ = "restructuredtext en"

from mx.DateTime import now

from cubicweb import UnknownProperty, ValidationError, BadConnectionId

from cubicweb.common.uilib import soup2xhtml

from cubicweb.server.pool import Operation, LateOperation, PreCommitOperation
from cubicweb.server.hookhelper import (check_internal_entity, previous_state,
                                     get_user_sessions, rproperty)
from cubicweb.server.repository import FTIndexEntityOp

def relation_deleted(session, eidfrom, rtype, eidto):
    session.add_query_data('pendingrelations', (eidfrom, rtype, eidto))
    

# 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
    """
    if not 'creation_date' in entity:
        entity['creation_date'] = now()
    if not 'modification_date' in entity:
        entity['modification_date'] = now()

def setmtime_before_update_entity(session, entity):
    """update an entity -> set modification date"""
    if not 'modification_date' in entity:
        entity['modification_date'] = now()
        
class SetCreatorOp(PreCommitOperation):
        
    def precommit_event(self):
        if self.eid in self.session.query_data('pendingeids', ()):
            # entity have been created and deleted in the same transaction
            return
        ueid = self.session.user.eid
        execute = self.session.unsafe_execute
        if not execute('Any X WHERE X created_by U, X eid %(x)s',
                       {'x': self.eid}, 'x'): 
            execute('SET X created_by U WHERE X eid %(x)s, U eid %(u)s',
                    {'x': self.eid, 'u': ueid}, 'x')

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.unsafe_execute('SET X owned_by U WHERE X eid %(x)s, U eid %(u)s',
                               {'x': entity.eid, 'u': asession.user.eid}, 'x')
        SetCreatorOp(asession, eid=entity.eid)

def setis_after_add_entity(session, entity):
    """create a new entity -> set is relation"""
    session.unsafe_execute('SET X is E WHERE X eid %(x)s, E name %(name)s',
                           {'x': entity.eid, 'name': entity.id}, 'x')
    # XXX < 2.50 bw compat
    if not session.get_shared_data('do-not-insert-is_instance_of'):
        basetypes = entity.e_schema.ancestors() + [entity.e_schema]
        session.unsafe_execute('SET X is_instance_of E WHERE X eid %%(x)s, E name IN (%s)' %
                               ','.join("'%s'" % str(etype) for etype in basetypes),
                               {'x': entity.eid}, 'x')

def setowner_after_add_user(session, entity):
    """when a user has been created, add owned_by relation on itself"""
    session.unsafe_execute('SET X owned_by X WHERE X eid %(x)s',
                           {'x': entity.eid}, 'x')

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(eidto))
    elif ftcontainer == 'object':
        FTIndexEntityOp(session, entity=session.entity(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(eidto))
        FTIndexEntityOp(session, entity=session.entity(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
        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 'EUser' in hm.schema:
        hm.register_hook(setowner_after_add_user, 'after_add_entity', 'EUser')
            
# 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
        if not self.eid in session.query_data('pendingeids', ()):
            etype = session.describe(self.eid)[0]
            session.unsafe_execute('DELETE %s X WHERE X eid %%(x)s, NOT %s'
                                   % (etype, self.relation),
                                   {'x': self.eid}, 'x')
    
def handle_composite_before_del_relation(session, eidfrom, rtype, eidto):
    """delete the object of composite relation"""
    composite = rproperty(session, rtype, eidfrom, eidto, 'composite')
    if composite == 'subject':
        DelayedDeleteOp(session, eid=eidto, relation='Y %s X' % rtype)
    elif composite == 'object':
        DelayedDeleteOp(session, eid=eidfrom, relation='X %s Y' % rtype)

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.query_data('pendingeids', ())
        if eidfrom in pending:
            return
        if eidto in pending:
            return
        for constraint in self.constraints:
            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.
    """
    constraints = rproperty(session, rtype, eidfrom, eidto, 'constraints')
    if constraints:
        CheckConstraintsOperation(session, constraints=constraints,
                                  rdef=(eidfrom, rtype, eidto))

def uniquecstrcheck_before_modification(session, entity):
    eschema = entity.e_schema
    for attr, val in entity.items():
        if val is None:
            continue
        if eschema.subject_relation(attr).is_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})




class tidy_html_fields(object):
    """tidy HTML in rich text strings

    FIXME: (adim) the whole idea of having a class is to store the
    event type. There might be another way to get dynamically the
    event inside the hook function.
    """
    # FIXME hooks manager use func_name to register
    func_name = 'tidy_html_field'
    
    def __init__(self, event):
        self.event = event

    def __call__(self, session, entity):
        for attr in entity.formatted_attrs():
            value = entity.get(attr)
            # text was not changed
            if self.event == 'before_add_entity':
                fmt = entity.get('%s_format' % attr)
            else:
                fmt = entity.get_value('%s_format' % attr)
            if value and fmt == 'text/html':
                entity[attr] = soup2xhtml(value, session.encoding)


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.query_data('pendingeids', ()):
            return
        if self.session.unsafe_execute(*self._rql()).rowcount < 1:
            etype = self.session.describe(self.eid)[0]
            msg = self.session._('at least one relation %(rtype)s is required on %(etype)s (%(eid)s)')
            raise ValidationError(self.eid, {self.rtype: msg % {'rtype': self.rtype,
                                                                'etype': etype,
                                                                'eid': self.eid}})
    
    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"""
    eid = entity.eid
    for rschema, targetschemas, x in entity.e_schema.relation_definitions():
        # skip automatically handled relations
        if rschema.type in ('owned_by', 'created_by', 'is', 'is_instance_of'):
            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"""
    card = rproperty(session, rtype, eidfrom, eidto, 'cardinality')
    pendingeids = session.query_data('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', 'EGroup')
    
    #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(tidy_html_fields('before_add_entity'), 'before_add_entity', '')
    hm.register_hook(tidy_html_fields('before_update_entity'), 'before_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', 'EUser')
    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 ###########################################################

def before_add_in_state(session, fromeid, rtype, toeid):
    """check the transition is allowed and record transition information
    """
    assert rtype == 'in_state'
    state = previous_state(session, fromeid)
    etype = session.describe(fromeid)[0]
    if not (session.is_super_session or 'managers' in session.user.groups):
        if not state is None:
            entity = session.entity(fromeid)
            # we should find at least one transition going to this state
            try:
                iter(state.transitions(entity, toeid)).next()
            except StopIteration:
                msg = session._('transition is not allowed')
                raise ValidationError(fromeid, {'in_state': msg})
        else:
            # not a transition
            # check state is initial state if the workflow defines one
            isrset = session.unsafe_execute('Any S WHERE ET initial_state S, ET name %(etype)s',
                                            {'etype': etype})
            if isrset and not toeid == isrset[0][0]:
                msg = session._('not the initial state for this entity')
                raise ValidationError(fromeid, {'in_state': msg})
    eschema = session.repo.schema[etype]
    if not 'wf_info_for' in eschema.object_relations():
        # workflow history not activated for this entity type
        return
    rql = 'INSERT TrInfo T: T wf_info_for E, T to_state DS, T comment %(comment)s'
    args = {'comment': session.get_shared_data('trcomment', None, pop=True),
            'e': fromeid, 'ds': toeid}
    cformat = session.get_shared_data('trcommentformat', None, pop=True)
    if cformat is not None:
        args['comment_format'] = cformat
        rql += ', T comment_format %(comment_format)s'
    restriction = ['DS eid %(ds)s, E eid %(e)s']
    if not state is None: # not a transition
        rql += ', T from_state FS'
        restriction.append('FS eid %(fs)s')
        args['fs'] = state.eid
    rql = '%s WHERE %s' % (rql, ', '.join(restriction))
    session.unsafe_execute(rql, args, 'e')


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

    def precommit_event(self):
        session = self.session
        entity = self.entity
        rset = session.execute('Any S WHERE ET initial_state S, ET name %(name)s',
                               {'name': str(entity.e_schema)})
        # if there is an initial state and the entity's state is not set,
        # use the initial state as a default state
        pendingeids = session.query_data('pendingeids', ())
        if rset and not entity.eid in pendingeids and not entity.in_state:
            session.unsafe_execute('SET X in_state S WHERE X eid %(x)s, S eid %(s)s',
                                   {'x' : entity.eid, 's' : rset[0][0]}, 'x')


def set_initial_state_after_add(session, entity):
    SetInitialStateOp(session, entity=entity)
    
def _register_wf_hooks(hm):
    """register workflow related hooks on the hooks manager"""
    if 'in_state' in hm.schema:
        hm.register_hook(before_add_in_state, 'before_add_relation', 'in_state')
        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))


# EProperty hooks #############################################################


class DelEPropertyOp(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 ChangeEPropertyOp(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 AddEPropertyOp(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 ChangeEPropertyOp 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.unsafe_execute('SET P for_user U WHERE P eid %(x)s,U eid %(u)s',
                               {'x': entity.eid, 'u': session.user.eid}, 'x')
    else:
        AddEPropertyOp(session, eprop=entity)
        
def after_update_eproperty(session, entity):
    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):
            ChangeEPropertyOp(session, epropdict=session_.user.properties,
                              key=key, value=value)
    else:
        # site wide properties
        ChangeEPropertyOp(session, epropdict=session.vreg.eprop_values,
                          key=key, value=value)
        
def before_del_eproperty(session, eid):
    for eidfrom, rtype, eidto in session.query_data('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]
        DelEPropertyOp(session, epropdict=session.vreg.eprop_values, key=key)

def after_add_for_user(session, fromeid, rtype, toeid):
    if not session.describe(fromeid)[0] == 'EProperty':
        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):
        ChangeEPropertyOp(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):
        DelEPropertyOp(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', 'EProperty')
    hm.register_hook(after_update_eproperty, 'after_update_entity', 'EProperty')
    hm.register_hook(before_del_eproperty, 'before_delete_entity', 'EProperty')
    hm.register_hook(after_add_for_user, 'after_add_relation', 'for_user')
    hm.register_hook(before_del_for_user, 'before_delete_relation', 'for_user')