"""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 OrderedDict
from datetime import datetime
import sqlalchemy.event
import venusian
from pyramid.location import lineage
from pyramid.threadlocal import get_current_request
from sqlalchemy.orm import load_only
from sqlalchemy.orm import mapper
from sqlalchemy_utils.functions import has_changes
from zope.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 Principal
from kotti.security import get_principals
from kotti.security import list_groups
from kotti.security import list_groups_raw
from kotti.security import set_groups
from kotti.sqla import no_autoflush
[docs]class ObjectEvent:
"""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(OrderedDict):
# Source: http://stackoverflow.com/a/6190500/562769
def __init__(self, *a, **kw):
OrderedDict.__init__(self, *a, **kw)
self.default_factory = list
def __getitem__(self, key):
try:
return OrderedDict.__getitem__(self, key)
except KeyError:
return self.__missing__(key)
def __missing__(self, key):
if self.default_factory is None:
raise KeyError(key)
self[key] = value = self.default_factory()
return value
def __reduce__(self):
if self.default_factory is None:
args = tuple()
else:
args = (self.default_factory,)
return type(self), args, None, None, self.items()
[docs] def copy(self):
return self.__copy__()
def __copy__(self):
return type(self)(self.default_factory, self)
def __deepcopy__(self, memo):
import copy
return type(self)(self.default_factory, copy.deepcopy(self.items()))
def __repr__(self):
return "OrderedDefaultDict({}, {})".format(
self.default_factory,
OrderedDict.__repr__(self),
)
[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()
# noinspection PyUnusedLocal,PyShadowingNames
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()))
# noinspection PyUnusedLocal
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 triggered 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:
# Set owner metadata:
if obj.owner is None:
obj.owner = userid
# Add owner role for userid if it's not inherited already:
if "role:owner" not in list_groups(userid, obj):
groups = list_groups_raw(userid, obj) | {"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 triggered 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 triggered 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()
# noinspection PyUnusedLocal
[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 triggered 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 triggered 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):]
# noinspection PyUnusedLocal,SpellCheckingInspection
@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 != "":
# 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 = "/".join(node.__name__ for node in line[:-1])
if target.__parent__ is None and value == "":
# We're a new root object
target_path = "/"
else:
target_path += f"/{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:
# noinspection PyProtectedMember
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 = f"{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
# noinspection PyUnusedLocal,PyUnusedLocal,SpellCheckingInspection
@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__ != "":
# 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 = "/".join(node.__name__ for node in line)
target_path += f"/{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 = f"{child.__parent__.path}{child.__name__}/"
# noinspection PyPep8Naming
[docs]class subscribe:
"""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
# noinspection PyUnusedLocal
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
)
# noinspection PyUnusedLocal
[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)