Source code for kotti.events

"""This module includes a simple events system that allows users to
subscribe to specific events, and more particularly to *object events*
of specific object types.

See also: :ref:`events`.

Inheritance Diagram
-------------------

.. inheritance-diagram:: kotti.events

"""

from collections import defaultdict
from datetime import datetime
try:  # pragma: no cover
    from collections import OrderedDict
    OrderedDict  # pyflakes
except ImportError:  # pragma: no cover
    from ordereddict import OrderedDict

import sqlalchemy.event
from sqlalchemy.orm import load_only
import venusian
from sqlalchemy.orm import mapper
from pyramid.location import lineage
from pyramid.threadlocal import get_current_request
from zope.deprecation.deprecation import deprecated

from kotti import DBSession
from kotti.resources import Content
from kotti.resources import LocalGroup
from kotti.resources import Node
from kotti.resources import Tag
from kotti.resources import TagsToContents
from kotti.security import get_principals
from kotti.security import list_groups
from kotti.security import list_groups_raw
from kotti.security import Principal
from kotti.security import set_groups
from kotti.sqla import no_autoflush


[docs]class ObjectEvent(object): """Event related to an object.""" def __init__(self, object, request=None): """Constructor. :param object: The (content) object related to the event. This is an instance of :class:`kotti.resources.Node` or one its descendants for content related events, but it can be anything. :type object: arbitrary :param request: current request :type request: :class:`kotti.request.Request` """ self.object = object self.request = request
[docs]class ObjectInsert(ObjectEvent): """This event is emitted when an object is inserted into the DB."""
[docs]class ObjectUpdate(ObjectEvent): """This event is emitted when an object in the DB is updated."""
[docs]class ObjectDelete(ObjectEvent): """This event is emitted when an object is deleted from the DB."""
[docs]class ObjectAfterDelete(ObjectEvent): """This event is emitted after an object has been deleted from the DB. .. deprecated:: 0.9 """
deprecated('ObjectAfterDelete', "The ObjectAfterDelete event is deprecated and will be no longer " "available starting with Kotti 0.10.")
[docs]class UserDeleted(ObjectEvent): """This event is emitted when an user object is deleted from the DB."""
[docs]class DispatcherDict(defaultdict, OrderedDict): """Base class for dispatchers""" def __init__(self, *args, **kwargs): defaultdict.__init__(self, list) OrderedDict.__init__(self, *args, **kwargs)
[docs]class Dispatcher(DispatcherDict): """Dispatches based on event type. >>> class BaseEvent(object): pass >>> class SubEvent(BaseEvent): pass >>> class UnrelatedEvent(object): pass >>> def base_listener(event): ... print 'Called base listener' >>> def sub_listener(event): ... print 'Called sub listener' >>> def unrelated_listener(event): ... print 'Called unrelated listener' ... return 1 >>> dispatcher = Dispatcher() >>> dispatcher[BaseEvent].append(base_listener) >>> dispatcher[SubEvent].append(sub_listener) >>> dispatcher[UnrelatedEvent].append(unrelated_listener) >>> dispatcher(BaseEvent()) Called base listener [None] >>> dispatcher(SubEvent()) Called base listener Called sub listener [None, None] >>> dispatcher(UnrelatedEvent()) Called unrelated listener [1] """ def __call__(self, event): results = [] for event_type, handlers in self.items(): if isinstance(event, event_type): for handler in handlers: results.append(handler(event)) return results
[docs]class ObjectEventDispatcher(DispatcherDict): """Dispatches based on both event type and object type. >>> class BaseObject(object): pass >>> class SubObject(BaseObject): pass >>> def base_listener(event): ... return 'base' >>> def subobj_insert_listener(event): ... return 'sub' >>> def all_listener(event): ... return 'all' >>> dispatcher = ObjectEventDispatcher() >>> dispatcher[(ObjectEvent, BaseObject)].append(base_listener) >>> dispatcher[(ObjectInsert, SubObject)].append(subobj_insert_listener) >>> dispatcher[(ObjectEvent, None)].append(all_listener) >>> dispatcher(ObjectEvent(BaseObject())) ['base', 'all'] >>> dispatcher(ObjectInsert(BaseObject())) ['base', 'all'] >>> dispatcher(ObjectEvent(SubObject())) ['base', 'all'] >>> dispatcher(ObjectInsert(SubObject())) ['base', 'sub', 'all'] """ def __call__(self, event): results = [] for (evtype, objtype), handlers in self.items(): if (isinstance(event, evtype) and (objtype is None or isinstance(event.object, objtype))): for handler in handlers: results.append(handler(event)) return results
def clear(): listeners.clear() objectevent_listeners.clear() listeners[ObjectEvent].append(objectevent_listeners) listeners = Dispatcher() notify = listeners.__call__ objectevent_listeners = ObjectEventDispatcher() clear() def _after_delete(mapper, connection, target): """ Trigger the Kotti event :class:``ObjectAfterDelete``. :param mapper: SQLAlchemy mapper :type mapper: :class:`sqlalchemy.orm.mapper.Mapper` :param connection: SQLAlchemy connection :type connection: :class:`sqlalchemy.engine.base.Connection` :param target: SQLAlchemy declarative class that is used :type target: Class as returned by ``declarative_base()`` """ notify(ObjectAfterDelete(target, get_current_request())) def _before_flush(session, flush_context, instances): """Trigger the following Kotti :class:``ObjectEvent`` events in this order: - :class:``ObjectUpdate`` - :class:``ObjectInsert`` - :class:``ObjectDelete`` """ req = get_current_request() for obj in session.dirty: if session.is_modified(obj, include_collections=False): # XXX ? notify(ObjectUpdate(obj, req)) for obj in session.new: notify(ObjectInsert(obj, req)) for obj in session.deleted: notify(ObjectDelete(obj, req))
[docs]def set_owner(event): """Set ``owner`` of the object that triggered the event. :param event: event that trigerred this handler. :type event: :class:`ObjectInsert` """ obj, request = event.object, event.request if request is not None and isinstance(obj, Node) and obj.owner is None: userid = request.authenticated_userid if userid is not None: userid = unicode(userid) # Set owner metadata: obj.owner = userid # Add owner role for userid if it's not inherited already: if u'role:owner' not in list_groups(userid, obj): groups = list_groups_raw(userid, obj) | set([u'role:owner']) set_groups(userid, obj, groups)
[docs]def set_creation_date(event): """Set ``creation_date`` of the object that triggered the event. :param event: event that trigerred this handler. :type event: :class:`ObjectInsert` """ obj = event.object if obj.creation_date is None: obj.creation_date = obj.modification_date = datetime.now()
[docs]def set_modification_date(event): """Update ``modification_date`` of the object that triggered the event. :param event: event that trigerred this handler. :type event: :class:`ObjectUpdate` """ event.object.modification_date = datetime.now()
[docs]def delete_orphaned_tags(event): """Delete Tag instances / records when they are not associated with any content. :param event: event that trigerred this handler. :type event: :class:`ObjectAfterDelete` """ DBSession.query(Tag).filter(~Tag.content_tags.any()).delete( synchronize_session=False)
[docs]def cleanup_user_groups(event): """Remove a deleted group from the groups of a user/group and remove all local group entries of it. :param event: event that trigerred this handler. :type event: :class:`UserDeleted` """ name = event.object.name if name.startswith("group:"): principals = get_principals() users_groups = [p for p in principals if name in principals[p].groups] for user_or_group in users_groups: principals[user_or_group].groups.remove(name) DBSession.query(LocalGroup).filter( LocalGroup.principal_name == name).delete()
[docs]def reset_content_owner(event): """Reset the owner of the content from the deleted owner. :param event: event that trigerred this handler. :type event: :class:`UserDeleted` """ contents = DBSession.query(Content).filter( Content.owner == event.object.name).all() for content in contents: content.owner = None
def _update_children_paths(old_parent_path, new_parent_path): for child in DBSession.query(Node).options( load_only('path', 'type')).filter( Node.path.startswith(old_parent_path)): if child.path == new_parent_path: # The child is the node itself and has already be renamed. # Nothing to do! continue child.path = new_parent_path + child.path[len(old_parent_path):] @no_autoflush def _set_path_for_new_name(target, value, oldvalue, initiator): """Triggered whenever the Node's 'name' attribute is set. Is called with all kind of weird edge cases, e.g. name is 'None', parent is 'None' etc. """ if getattr(target, '_kotti_set_path_for_new_name', False): # we're being called recursively (see below) return if value is None: # Our name is about to be set to 'None', so skip. return if target.__parent__ is None and value != u'': # Our parent hasn't been set yet. Skip, unless we're the root # object (which always has an empty string as name). return old_path = target.path line = tuple(reversed(tuple(lineage(target)))) target_path = u'/'.join(node.__name__ for node in line[:-1]) if target.__parent__ is None and value == u'': # We're a new root object target_path = u'/' else: target_path += u'/{0}/'.format(value) target.path = target_path # We need to set the name to value here so that the subsequent # UPDATE in _update_children_paths will include the new 'name' # already. We have to make sure that we don't end up in an # endless recursion, which is why we set this flag: target._kotti_set_path_for_new_name = True try: target.name = value finally: del target._kotti_set_path_for_new_name if old_path and target.id is not None: _update_children_paths(old_path, target_path) else: for child in _all_children(target): child.path = u'{0}{1}/'.format(child.__parent__.path, child.__name__) def _all_children(item, _all=None): if _all is None: _all = [] for child in item.children: _all.append(child) _all_children(child, _all) return _all @no_autoflush def _set_path_for_new_parent(target, value, oldvalue, initiator): """Triggered whenever the Node's 'parent' attribute is set. """ if value is None: # The parent is about to be set to 'None', so skip. return if target.__name__ is None: # The object's name is still 'None', so skip. return if value.__parent__ is None and value.__name__ != u'': # Our parent doesn't have a parent, and it's not root either. return old_path = target.path line = tuple(reversed(tuple(lineage(value)))) names = [node.__name__ for node in line] if None in names: # If any of our parents don't have a name yet, skip return target_path = u'/'.join(node.__name__ for node in line) target_path += u'/{0}/'.format(target.__name__) target.path = target_path if old_path and target.id is not None: _update_children_paths(old_path, target_path) else: # We might not have had a path before, but we might still have # children. This is the case when we create an object with # children before we assign the object itself to a parent. for child in _all_children(target): child.path = u'{0}{1}/'.format(child.__parent__.path, child.__name__)
[docs]class subscribe(object): """Function decorator to attach the decorated function as a handler for a Kotti event. Example:: from kotti.events import ObjectInsert from kotti.events import subscribe from kotti.resources import Document @subscribe() def on_all_events(event): # this will be executed on *every* event print "Some kind of event occured" @subscribe(ObjectInsert) def on_insert(event): # this will be executed on every object insert context = event.object request = event.request print "Object insert" @subscribe(ObjectInsert, Document) def on_document_insert(event): # this will only be executed on object inserts if the object is # is an instance of Document context = event.object request = event.request print "Document insert" """ venusian = venusian # needed for testing def __init__(self, evttype=object, objtype=None): """Constructor. :param evttype: Event to subscribe to. :type evttype: class:`ObjectEvent` or descendant :param objtype: Object type on which the handler will be called :type objtype: class:`kotti.resources.Node` or descendant. """ self.evttype = evttype self.objtype = objtype def register(self, context, name, obj): if issubclass(self.evttype, ObjectEvent): objectevent_listeners[(self.evttype, self.objtype)].append(obj) else: listeners[self.evttype].append(obj) def __call__(self, wrapped): self.venusian.attach(wrapped, self.register, category='kotti') return wrapped
_WIRED_SQLALCHMEY = False
[docs]def wire_sqlalchemy(): # pragma: no cover """ Connect SQLAlchemy events to their respective handler function (that fires the corresponding Kotti event). """ global _WIRED_SQLALCHMEY if _WIRED_SQLALCHMEY: return else: _WIRED_SQLALCHMEY = True sqlalchemy.event.listen(mapper, 'after_delete', _after_delete) sqlalchemy.event.listen(DBSession, 'before_flush', _before_flush) # Update the 'path' attribute on changes to 'name' or 'parent' sqlalchemy.event.listen( Node.name, 'set', _set_path_for_new_name, propagate=True) sqlalchemy.event.listen( Node.parent, 'set', _set_path_for_new_parent, propagate=True)
[docs]def includeme(config): """ Pyramid includeme hook. :param config: app config :type config: :class:`pyramid.config.Configurator` """ from kotti.workflow import initialize_workflow # Subscribe to SQLAlchemy events and map these to Kotti events wire_sqlalchemy() # Set content owner on content creation objectevent_listeners[ (ObjectInsert, Content)].append(set_owner) # Set content creation date on content creation objectevent_listeners[ (ObjectInsert, Content)].append(set_creation_date) # Set content modification date on content updates objectevent_listeners[ (ObjectUpdate, Content)].append(set_modification_date) # Delete orphaned tags after a tag association has ben deleted objectevent_listeners[ (ObjectAfterDelete, TagsToContents)].append(delete_orphaned_tags) # Initialze the workflow on content creation. objectevent_listeners[ (ObjectInsert, Content)].append(initialize_workflow) # Perform some cleanup when a user or group is deleted objectevent_listeners[ (UserDeleted, Principal)].append(cleanup_user_groups) # Remove the owner from content when the corresponding user is deleted objectevent_listeners[ (UserDeleted, Principal)].append(reset_content_owner)