# -*- coding: utf-8 -*-
"""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
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 sqlalchemy_utils.functions import has_changes
from pyramid.location import lineage
from pyramid.threadlocal import get_current_request
from zope.deprecation.deprecation import deprecated
from kotti import DBSession
from kotti import get_settings
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, obj, request=None):
"""Constructor.
:param obj: 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 obj: arbitrary
:param request: current request
:type request: :class:`kotti.request.Request`
"""
self.object = obj
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):
userid = request.authenticated_userid
if userid is not None:
userid = unicode(userid)
# Set owner metadata:
if obj.owner is None:
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) | {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`
"""
exclude = []
for e in get_settings()['kotti.modification_date_excludes']:
if isinstance(event.object, e.class_):
exclude.append(e.key)
if has_changes(event.object, exclude=exclude):
event.object.modification_date = datetime.now()
[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)).order_by(Node.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 or value == oldvalue:
# 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)