hooks/notification.py
author Aurelien Campeas <aurelien.campeas@logilab.fr>
Tue, 01 Jul 2014 16:55:49 +0200
changeset 10351 91e63306e277
parent 9632 c60c8dec0e0e
child 11084 ffd615e626f9
permissions -rw-r--r--
[connection] replace .running_dbapi_query with .hooks_in_progress The thing was badly named. It tries to help distinguish between queries issued directly by the programmer (e.g in the views: cnx.execute(...)) from queries issued from the hooks, operations ... or even the repository or the native source objects. It worked heuristically being associated with the security being disabled. We provide a better name and an implementation distinct from the security management methods. Related to #3933480.

# copyright 2003-2015 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
"""some hooks to handle notification on entity's changes"""

__docformat__ = "restructuredtext en"

from logilab.common.textutils import normalize_text
from logilab.common.deprecation import deprecated

from cubicweb import RegistryNotFound
from cubicweb.predicates import is_instance
from cubicweb.server import hook
from cubicweb.sobjects.supervising import SupervisionMailOp


@deprecated('[3.17] use notify_on_commit instead')
def RenderAndSendNotificationView(cnx, view, viewargs=None):
    notify_on_commit(cnx, view, viewargs)


def notify_on_commit(cnx, view, viewargs=None):
    """register a notification view (see
    :class:`~cubicweb.sobjects.notification.NotificationView`) to be sent at
    post-commit time, ie only if the transaction has succeeded.

    `viewargs` is an optional dictionary containing extra argument to be given
    to :meth:`~cubicweb.sobjects.notification.NotificationView.render_and_send`
    """
    if viewargs is None:
        viewargs = {}
    notif_op = _RenderAndSendNotificationOp.get_instance(cnx)
    notif_op.add_data((view, viewargs))


class _RenderAndSendNotificationOp(hook.DataOperationMixIn, hook.Operation):
    """End of the notification chain. Do render and send views after commit

    All others Operations end up adding data to this Operation.
    The notification are done on ``postcommit_event`` to make sure to prevent
    sending notification about rolled back data.
    """

    containercls = list

    def postcommit_event(self):
        deleted = self.cnx.deleted_in_transaction
        for view, viewargs in self.get_data():
            if view.cw_rset is not None:
                if not view.cw_rset:
                    # entity added and deleted in the same transaction
                    # (cache effect)
                    continue
                elif deleted(view.cw_rset[view.cw_row or 0][view.cw_col or 0]):
                    # entity added and deleted in the same transaction
                    continue
            try:
                view.render_and_send(**viewargs)
            except Exception:
                # error in post commit are not propagated
                # We keep this logic here to prevent a small notification error
                # to prevent them all.
                self.exception('Notification failed')


class NotificationHook(hook.Hook):
    __abstract__ = True
    category = 'notification'

    def select_view(self, vid, rset, row=0, col=0):
        try:
            return self._cw.vreg['views'].select_or_none(vid, self._cw, rset=rset,
                                                         row=row, col=col)
        except RegistryNotFound: # can happen in some config
                                 # (e.g. repo only config with no
                                 # notification views registered by
                                 # the instance's cubes)
            return None


class StatusChangeHook(NotificationHook):
    """notify when a workflowable entity has its state modified"""
    __regid__ = 'notifystatuschange'
    __select__ = NotificationHook.__select__ & is_instance('TrInfo')
    events = ('after_add_entity',)

    def __call__(self):
        entity = self.entity
        if not entity.from_state: # not a transition
            return
        rset = entity.related('wf_info_for')
        view = self.select_view('notif_status_change', rset=rset, row=0)
        if view is None:
            return
        comment = entity.printable_value('comment', format='text/plain')
        # XXX don't try to wrap rest until we've a proper transformation (see
        # #103822)
        if comment and entity.comment_format != 'text/rest':
            comment = normalize_text(comment, 80)
        viewargs = {'comment': comment,
                    'previous_state': entity.previous_state.name,
                    'current_state': entity.new_state.name}
        notify_on_commit(self._cw, view, viewargs=viewargs)

class RelationChangeHook(NotificationHook):
    __regid__ = 'notifyrelationchange'
    events = ('before_add_relation', 'after_add_relation',
              'before_delete_relation', 'after_delete_relation')

    def __call__(self):
        """if a notification view is defined for the event, send notification
        email defined by the view
        """
        rset = self._cw.eid_rset(self.eidfrom)
        view = self.select_view('notif_%s_%s' % (self.event,  self.rtype),
                                rset=rset, row=0)
        if view is None:
            return
        notify_on_commit(self._cw, view)


class EntityChangeHook(NotificationHook):
    """if a notification view is defined for the event, send notification
    email defined by the view
    """
    __regid__ = 'notifyentitychange'
    events = ('after_add_entity', 'after_update_entity')

    def __call__(self):
        rset = self.entity.as_rset()
        view = self.select_view('notif_%s' % self.event, rset=rset, row=0)
        if view is None:
            return
        notify_on_commit(self._cw, view)


class EntityUpdatedNotificationOp(hook.SingleLastOperation):
    """scrap all changed entity to prepare a Notification Operation for them"""

    def precommit_event(self):
        # precommit event that creates postcommit operation
        cnx = self.cnx
        for eid in cnx.transaction_data['changes']:
            view = cnx.vreg['views'].select('notif_entity_updated', cnx,
                                            rset=cnx.eid_rset(eid),
                                            row=0)
            notify_on_commit(self.cnx, view,
                    viewargs={'changes': cnx.transaction_data['changes'][eid]})


class EntityUpdateHook(NotificationHook):
    __regid__ = 'notifentityupdated'
    __abstract__ = True # do not register by default
    __select__ = NotificationHook.__select__ & hook.issued_from_user_query()
    events = ('before_update_entity',)
    skip_attrs = set()

    def __call__(self):
        cnx = self._cw
        if cnx.added_in_transaction(self.entity.eid):
            return # entity is being created
        # then compute changes
        attrs = [k for k in self.entity.cw_edited
                 if not k in self.skip_attrs]
        if not attrs:
            return
        changes = cnx.transaction_data.setdefault('changes', {})
        thisentitychanges = changes.setdefault(self.entity.eid, set())
        rqlsel, rqlrestr = [], ['X eid %(x)s']
        for i, attr in enumerate(attrs):
            var = chr(65+i)
            rqlsel.append(var)
            rqlrestr.append('X %s %s' % (attr, var))
        rql = 'Any %s WHERE %s' % (','.join(rqlsel), ','.join(rqlrestr))
        rset = cnx.execute(rql, {'x': self.entity.eid})
        for i, attr in enumerate(attrs):
            oldvalue = rset[0][i]
            newvalue = self.entity.cw_edited[attr]
            if oldvalue != newvalue:
                thisentitychanges.add((attr, oldvalue, newvalue))
        if thisentitychanges:
            EntityUpdatedNotificationOp(cnx)


# supervising ##################################################################

class SomethingChangedHook(NotificationHook):
    __regid__ = 'supervising'
    __select__ = NotificationHook.__select__ & hook.issued_from_user_query()
    events = ('before_add_relation', 'before_delete_relation',
              'after_add_entity', 'before_update_entity')

    def __call__(self):
        dest = self._cw.vreg.config['supervising-addrs']
        if not dest: # no supervisors, don't do this for nothing...
            return
        if self._call():
            SupervisionMailOp(self._cw)

    def _call(self):
        event = self.event.split('_', 1)[1]
        if event == 'update_entity':
            if self._cw.added_in_transaction(self.entity.eid):
                return False
            if self.entity.e_schema == 'CWUser':
                if not (frozenset(self.entity.cw_edited)
                        - frozenset(('eid', 'modification_date',
                                     'last_login_time'))):
                    # don't record last_login_time update which are done
                    # automatically at login time
                    return False
        self._cw.transaction_data.setdefault('pendingchanges', []).append(
            (event, self))
        return True


class EntityDeleteHook(SomethingChangedHook):
    __regid__ = 'supervisingentitydel'
    events = ('before_delete_entity',)

    def _call(self):
        try:
            title = self.entity.dc_title()
        except Exception:
            # may raise an error during deletion process, for instance due to
            # missing required relation
            title = '#%s' % self.entity.eid
        self._cw.transaction_data.setdefault('pendingchanges', []).append(
            ('delete_entity', (self.entity.eid, self.entity.cw_etype, title)))
        return True