notification.py 12.3 KB
Newer Older
1
# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
# 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.
#
11
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
12
13
14
15
16
17
# 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/>.
Sylvain Thénault's avatar
cleanup    
Sylvain Thénault committed
18
"""some views to handle notification on data changes"""
Adrien Di Mascio's avatar
Adrien Di Mascio committed
19

Sylvain Thénault's avatar
Sylvain Thénault committed
20

21
from cubicweb import _
Adrien Di Mascio's avatar
Adrien Di Mascio committed
22
23
24
25

from itertools import repeat

from logilab.common.textutils import normalize_text
26
from logilab.common.registry import yes
Adrien Di Mascio's avatar
Adrien Di Mascio committed
27

28
from cubicweb.view import Component, EntityView
29
from cubicweb.server.hook import SendMailOp
30
from cubicweb.mail import construct_message_id, format_mail
31
from cubicweb.server.session import Connection, InternalManager
Adrien Di Mascio's avatar
Adrien Di Mascio committed
32
33
34
35
36
37
38
39


class RecipientsFinder(Component):
    """this component is responsible to find recipients of a notification

    by default user's with their email set are notified if any, else the default
    email addresses specified in the configuration are used
    """
40
    __regid__ = 'recipients_finder'
41
    __select__ = yes()
42
    user_rql = ('Any X,E,A WHERE X is CWUser, X in_state S, S name "activated",'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
43
                'X primary_email E, E address A')
44

Adrien Di Mascio's avatar
Adrien Di Mascio committed
45
    def recipients(self):
46
        mode = self._cw.vreg.config['default-recipients-mode']
Adrien Di Mascio's avatar
Adrien Di Mascio committed
47
        if mode == 'users':
48
            execute = self._cw.execute
49
            dests = list(execute(self.user_rql, build_descr=True).entities())
Adrien Di Mascio's avatar
Adrien Di Mascio committed
50
        elif mode == 'default-dest-addrs':
51
52
            lang = self._cw.vreg.property_value('ui.language')
            dests = zip(self._cw.vreg.config['default-dest-addrs'], repeat(lang))
Sylvain Thénault's avatar
Sylvain Thénault committed
53
        else:  # mode == 'none'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
54
55
56
            dests = []
        return dests

57

Adrien Di Mascio's avatar
Adrien Di Mascio committed
58
59
# abstract or deactivated notification views and mixin ########################

60
61
62
63
64

class SkipEmail(Exception):
    """raise this if you decide to skip an email during its generation"""


65
class NotificationView(EntityView):
66
67
68
69
70
71
72
73
    """abstract view implementing the "email" API (eg to simplify sending
    notification)
    """
    # XXX refactor this class to work with len(rset) > 1

    msgid_timestamp = True

    # to be defined on concrete sub-classes
Sylvain Thénault's avatar
Sylvain Thénault committed
74
75
    content = None  # body of the mail
    message = None  # action verb of the subject
76
77
78

    # this is usually the method to call
    def render_and_send(self, **kwargs):
79
        """generate and send email messages for this view"""
80
81
82
83
84
        # render_emails changes self._cw so cache it here so all mails are sent
        # after we commit our transaction.
        cnx = self._cw
        for msg, recipients in self.render_emails(**kwargs):
            SendMailOp(cnx, recipients=recipients, msg=msg)
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111

    def cell_call(self, row, col=0, **kwargs):
        self.w(self._cw._(self.content) % self.context(**kwargs))

    def render_emails(self, **kwargs):
        """generate and send emails for this view (one per recipient)"""
        self._kwargs = kwargs
        recipients = self.recipients()
        if not recipients:
            self.info('skipping %s notification, no recipients', self.__regid__)
            return
        if self.cw_rset is not None:
            entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
            # if the view is using timestamp in message ids, no way to reference
            # previous email
            if not self.msgid_timestamp:
                refs = [self.construct_message_id(eid)
                        for eid in entity.cw_adapt_to('INotifiable').notification_references(self)]
            else:
                refs = ()
            msgid = self.construct_message_id(entity.eid)
        else:
            refs = ()
            msgid = None
        req = self._cw
        self.user_data = req.user_data()
        for something in recipients:
112
            if isinstance(something, tuple):
113
                emailaddr, lang = something
114
115
116
117
118
                user = InternalManager(lang=lang)
            else:
                emailaddr = something.cw_adapt_to('IEmailable').get_email()
                user = something
            # hi-jack self._cw to get a session for the returned user
119
            with Connection(self._cw.repo, user) as cnx:
120
                self._cw = cnx
121
                try:
122
123
                    # since the same view (eg self) may be called multiple time and we
                    # need a fresh stream at each iteration, reset it explicitly
124
                    self._w = None
125
                    try:
Julien Cristau's avatar
Julien Cristau committed
126
127
128
                        # XXX forcing the row & col here may make the content and
                        #     subject inconsistent because subject will depend on
                        #     self.cw_row & self.cw_col if they are set.
129
130
131
132
133
134
135
136
137
138
139
140
                        content = self.render(row=0, col=0, **kwargs)
                        subject = self.subject()
                    except SkipEmail:
                        continue
                    except Exception as ex:
                        # shouldn't make the whole transaction fail because of rendering
                        # error (unauthorized or such) XXX check it doesn't actually
                        # occurs due to rollback on such error
                        self.exception(str(ex))
                        continue
                    msg = format_mail(self.user_data, [emailaddr], content, subject,
                                      config=self._cw.vreg.config, msgid=msgid, references=refs)
141
                    yield msg, [emailaddr]
142
143
                finally:
                    self._cw = req
144

145
    # recipients handling ######################################################
146
147
148

    def recipients(self):
        """return a list of either 2-uple (email, language) or user entity to
149
        whom this email should be sent
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
        """
        finder = self._cw.vreg['components'].select(
            'recipients_finder', self._cw, rset=self.cw_rset,
            row=self.cw_row or 0, col=self.cw_col or 0)
        return finder.recipients()

    # email generation helpers #################################################

    def construct_message_id(self, eid):
        return construct_message_id(self._cw.vreg.config.appid, eid,
                                    self.msgid_timestamp)

    def format_field(self, attr, value):
        return ':%(attr)s: %(value)s' % {'attr': attr, 'value': value}

    def format_section(self, attr, value):
        return '%(attr)s\n%(ul)s\n%(value)s\n' % {
Sylvain Thénault's avatar
Sylvain Thénault committed
167
            'attr': attr, 'ul': '-' * len(attr), 'value': value}
168
169
170
171
172
173
174
175
176
177
178

    def subject(self):
        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
        subject = self._cw._(self.message)
        etype = entity.dc_type()
        eid = entity.eid
        login = self.user_data['login']
        return self._cw._('%(subject)s %(etype)s #%(eid)s (%(login)s)') % locals()

    def context(self, **kwargs):
        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
179
        for key, val in kwargs.items():
Denis Laxalde's avatar
Denis Laxalde committed
180
            if val and isinstance(val, str) and val.strip():
Sylvain Thénault's avatar
Sylvain Thénault committed
181
                kwargs[key] = self._cw._(val)
182
183
184
        kwargs.update({'user': self.user_data['login'],
                       'eid': entity.eid,
                       'etype': entity.dc_type(),
185
                       'url': entity.absolute_url(),
Sylvain Thénault's avatar
Sylvain Thénault committed
186
                       'title': entity.dc_long_title()})
187
188
189
        return kwargs


Adrien Di Mascio's avatar
Adrien Di Mascio committed
190
class StatusChangeMixIn(object):
191
    __regid__ = 'notif_status_change'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
192
193
194
195
196
197
198
199
200
201
202
203
    msgid_timestamp = True
    message = _('status changed')
    content = _("""
%(user)s changed status from <%(previous_state)s> to <%(current_state)s> for entity
'%(title)s'

%(comment)s

url: %(url)s
""")


204
205
206
207
208
209
210
211
212
###############################################################################
# Actual notification views.                                                  #
#                                                                             #
# disable them at the recipients_finder level if you don't want them          #
###############################################################################

# XXX should be based on dc_title/dc_description, no?

class ContentAddedView(NotificationView):
213
214
215
216
217
218
219
    """abstract class for notification on entity/relation

    all you have to do by default is :
    * set id and __select__ attributes to match desired events and entity types
    * set a content attribute to define the content of the email (unless you
      override call)
    """
220
    __abstract__ = True
221
    __regid__ = 'notif_after_add_entity'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
222
223
224
225
226
227
228
229
230
    msgid_timestamp = False
    message = _('new')
    content = """
%(title)s

%(content)s

url: %(url)s
"""
231
232
    # to be defined on concrete sub-classes
    content_attr = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
233
234

    def context(self, **kwargs):
235
        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
236
237
        content = entity.printable_value(self.content_attr, format='text/plain')
        if content:
238
239
240
241
242
243
            contentformat = getattr(entity, self.content_attr + '_format',
                                    'text/rest')
            # XXX don't try to wrap rest until we've a proper transformation (see
            # #103822)
            if contentformat != 'text/rest':
                content = normalize_text(content, 80)
sylvain.thenault@logilab.fr's avatar
oops    
sylvain.thenault@logilab.fr committed
244
        return super(ContentAddedView, self).context(content=content, **kwargs)
245

Adrien Di Mascio's avatar
Adrien Di Mascio committed
246
    def subject(self):
247
        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
Sylvain Thénault's avatar
Sylvain Thénault committed
248
249
        return u'%s #%s (%s)' % (self._cw.__('New %s' % entity.e_schema),
                                 entity.eid, self.user_data['login'])
Adrien Di Mascio's avatar
Adrien Di Mascio committed
250

Sylvain Thénault's avatar
Sylvain Thénault committed
251

252
def format_value(value):
Denis Laxalde's avatar
Denis Laxalde committed
253
    if isinstance(value, str):
254
255
256
        return u'"%s"' % value
    return value

Sylvain Thénault's avatar
Sylvain Thénault committed
257

258
259
260
261
262
263
264
265
266
class EntityUpdatedNotificationView(NotificationView):
    """abstract class for notification on entity/relation

    all you have to do by default is :
    * set id and __select__ attributes to match desired events and entity types
    * set a content attribute to define the content of the email (unless you
      override call)
    """
    __abstract__ = True
Sylvain Thénault's avatar
Sylvain Thénault committed
267
    __regid__ = 'notif_entity_updated'
268
    msgid_timestamp = True
269
270
271
272
273
274
275
276
277
278
    message = _('updated')
    no_detailed_change_attrs = ()
    content = """
Properties have been updated by %(user)s:

%(changes)s

url: %(url)s
"""

279
    def context(self, changes=(), **kwargs):
280
        context = super(EntityUpdatedNotificationView, self).context(**kwargs)
Sylvain Thénault's avatar
Sylvain Thénault committed
281
        _ = self._cw._
282
        formatted_changes = []
Sylvain Thénault's avatar
Sylvain Thénault committed
283
        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
284
285
        for attr, oldvalue, newvalue in sorted(changes):
            # check current user has permission to see the attribute
Sylvain Thénault's avatar
Sylvain Thénault committed
286
            rschema = self._cw.vreg.schema[attr]
287
            if rschema.final:
Sylvain Thénault's avatar
Sylvain Thénault committed
288
                rdef = entity.e_schema.rdef(rschema)
Sylvain Thénault's avatar
Sylvain Thénault committed
289
                if not rdef.has_perm(self._cw, 'read', eid=self.cw_rset[0][0]):
290
291
                    continue
            # XXX suppose it's a subject relation...
292
293
            elif not rschema.has_perm(self._cw, 'read',
                                      fromeid=self.cw_rset[0][0]):
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
                continue
            if attr in self.no_detailed_change_attrs:
                msg = _('%s updated') % _(attr)
            elif oldvalue not in (None, ''):
                msg = _('%(attr)s updated from %(oldvalue)s to %(newvalue)s') % {
                    'attr': _(attr),
                    'oldvalue': format_value(oldvalue),
                    'newvalue': format_value(newvalue)}
            else:
                msg = _('%(attr)s set to %(newvalue)s') % {
                    'attr': _(attr), 'newvalue': format_value(newvalue)}
            formatted_changes.append('* ' + msg)
        if not formatted_changes:
            # current user isn't allowed to see changes, skip this notification
            raise SkipEmail()
        context['changes'] = '\n'.join(formatted_changes)
        return context

    def subject(self):
Sylvain Thénault's avatar
Sylvain Thénault committed
313
        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
Sylvain Thénault's avatar
Sylvain Thénault committed
314
315
        return u'%s #%s (%s)' % (self._cw.__('Updated %s' % entity.e_schema),
                                 entity.eid, self.user_data['login'])