sobjects/notification.py
changeset 0 b97547f5f1fa
child 388 4e23b542f8ad
equal deleted inserted replaced
-1:000000000000 0:b97547f5f1fa
       
     1 """some hooks and views to handle notification on entity's changes
       
     2 
       
     3 :organization: Logilab
       
     4 :copyright: 2001-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     5 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     6 """
       
     7 __docformat__ = "restructuredtext en"
       
     8 
       
     9 from base64 import b64encode, b64decode
       
    10 from itertools import repeat
       
    11 from time import time
       
    12 try:
       
    13     from socket import gethostname
       
    14 except ImportError:
       
    15     def gethostname():
       
    16         return 'XXX'
       
    17 
       
    18 from logilab.common.textutils import normalize_text
       
    19 
       
    20 from cubicweb import RegistryException
       
    21 from cubicweb.common.view import EntityView
       
    22 from cubicweb.common.appobject import Component
       
    23 from cubicweb.common.registerers import accepts_registerer
       
    24 from cubicweb.common.selectors import accept_selector
       
    25 from cubicweb.common.mail import format_mail
       
    26 
       
    27 from cubicweb.server.pool import PreCommitOperation
       
    28 from cubicweb.server.hookhelper import SendMailOp
       
    29 from cubicweb.server.hooksmanager import Hook
       
    30 
       
    31 _ = unicode
       
    32 
       
    33 class RecipientsFinder(Component):
       
    34     """this component is responsible to find recipients of a notification
       
    35 
       
    36     by default user's with their email set are notified if any, else the default
       
    37     email addresses specified in the configuration are used
       
    38     """
       
    39     id = 'recipients_finder'
       
    40     __registerer__ = accepts_registerer
       
    41     __selectors__ = (accept_selector,)
       
    42     accepts = ('Any',)
       
    43     user_rql = ('Any X,E,A WHERE X is EUser, X in_state S, S name "activated",'
       
    44                 'X primary_email E, E address A')
       
    45     
       
    46     def recipients(self):
       
    47         mode = self.config['default-recipients-mode']
       
    48         if mode == 'users':
       
    49             # use unsafe execute else we may don't have the right to see users
       
    50             # to notify...
       
    51             execute = self.req.unsafe_execute
       
    52             dests = [(u.get_email(), u.property_value('ui.language'))
       
    53                      for u in execute(self.user_rql, build_descr=True, propagate=True).entities()]
       
    54         elif mode == 'default-dest-addrs':
       
    55             lang = self.vreg.property_value('ui.language')
       
    56             dests = zip(self.config['default-dest-addrs'], repeat(lang))
       
    57         else: # mode == 'none'
       
    58             dests = []
       
    59         return dests
       
    60 
       
    61     
       
    62 # hooks #######################################################################
       
    63 
       
    64 class RenderAndSendNotificationView(PreCommitOperation):
       
    65     """delay rendering of notification view until precommit"""
       
    66     def precommit_event(self):
       
    67         if self.view.rset[0][0] in self.session.query_data('pendingeids', ()):
       
    68             return # entity added and deleted in the same transaction
       
    69         self.view.render_and_send(**getattr(self, 'viewargs', {}))
       
    70         
       
    71 class StatusChangeHook(Hook):
       
    72     """notify when a workflowable entity has its state modified"""
       
    73     events = ('after_add_entity',)
       
    74     accepts = ('TrInfo',)
       
    75     
       
    76     def call(self, session, entity):
       
    77         if not entity.from_state: # not a transition
       
    78             return
       
    79         rset = entity.related('wf_info_for')
       
    80         try:
       
    81             view = session.vreg.select_view('notif_status_change',
       
    82                                             session, rset, row=0)
       
    83         except RegistryException:
       
    84             return
       
    85         comment = entity.printable_value('comment', format='text/plain')
       
    86         if comment:
       
    87             comment = normalize_text(comment, 80,
       
    88                                      rest=entity.comment_format=='text/rest')
       
    89         RenderAndSendNotificationView(session, view=view, viewargs={
       
    90             'comment': comment, 'previous_state': entity.previous_state.name,
       
    91             'current_state': entity.new_state.name})
       
    92 
       
    93 
       
    94 class RelationChangeHook(Hook):
       
    95     events = ('before_add_relation', 'after_add_relation',
       
    96               'before_delete_relation', 'after_delete_relation')
       
    97     accepts = ('Any',)
       
    98     def call(self, session, fromeid, rtype, toeid):
       
    99         """if a notification view is defined for the event, send notification
       
   100         email defined by the view
       
   101         """
       
   102         rset = session.eid_rset(fromeid)
       
   103         vid = 'notif_%s_%s' % (self.event,  rtype)
       
   104         try:
       
   105             view = session.vreg.select_view(vid, session, rset, row=0)
       
   106         except RegistryException:
       
   107             return
       
   108         RenderAndSendNotificationView(session, view=view)
       
   109 
       
   110 
       
   111 class EntityChangeHook(Hook):
       
   112     events = ('after_add_entity',
       
   113               'after_update_entity')
       
   114     accepts = ('Any',)
       
   115     def call(self, session, entity):
       
   116         """if a notification view is defined for the event, send notification
       
   117         email defined by the view
       
   118         """
       
   119         rset = entity.as_rset()
       
   120         vid = 'notif_%s' % self.event
       
   121         try:
       
   122             view = session.vreg.select_view(vid, session, rset, row=0)
       
   123         except RegistryException:
       
   124             return
       
   125         RenderAndSendNotificationView(session, view=view)
       
   126 
       
   127 
       
   128 # abstract or deactivated notification views and mixin ########################
       
   129 
       
   130 class NotificationView(EntityView):
       
   131     """abstract view implementing the email API
       
   132 
       
   133     all you have to do by default is :
       
   134     * set id and accepts attributes to match desired events and entity types
       
   135     * set a content attribute to define the content of the email (unless you
       
   136       override call)
       
   137     """
       
   138     accepts = ()
       
   139     id = None
       
   140     msgid_timestamp = True
       
   141     
       
   142     def recipients(self):
       
   143         finder = self.vreg.select_component('recipients_finder',
       
   144                                             req=self.req, rset=self.rset)
       
   145         return finder.recipients()
       
   146         
       
   147     def subject(self):
       
   148         entity = self.entity(0, 0)
       
   149         subject = self.req._(self.message)
       
   150         etype = entity.dc_type()
       
   151         eid = entity.eid
       
   152         login = self.user_login()
       
   153         return self.req._('%(subject)s %(etype)s #%(eid)s (%(login)s)') % locals()
       
   154 
       
   155     def user_login(self):
       
   156         # req is actually a session (we are on the server side), and we have to
       
   157         # prevent nested internal session
       
   158         return self.req.actual_session().user.login
       
   159     
       
   160     def context(self, **kwargs):
       
   161         entity = self.entity(0, 0)
       
   162         for key, val in kwargs.iteritems():
       
   163             if val and val.strip():
       
   164                 kwargs[key] = self.req._(val)
       
   165         kwargs.update({'user': self.user_login(),
       
   166                        'eid': entity.eid,
       
   167                        'etype': entity.dc_type(),
       
   168                        'url': entity.absolute_url(),
       
   169                        'title': entity.dc_long_title(),})
       
   170         return kwargs
       
   171     
       
   172     def cell_call(self, row, col=0, **kwargs):
       
   173         self.w(self.req._(self.content) % self.context(**kwargs))
       
   174 
       
   175     def construct_message_id(self, eid):
       
   176         return construct_message_id(self.config.appid, eid, self.msgid_timestamp)
       
   177 
       
   178     def render_and_send(self, **kwargs):
       
   179         """generate and send an email message for this view"""
       
   180         self._kwargs = kwargs
       
   181         recipients = self.recipients()
       
   182         if not recipients:
       
   183             self.info('skipping %s%s notification which has no recipients',
       
   184                       self.id, self.accepts)
       
   185             return
       
   186         if not isinstance(recipients[0], tuple):
       
   187             from warnings import warn
       
   188             warn('recipients should now return a list of 2-uple (email, language)',
       
   189                  DeprecationWarning, stacklevel=1)
       
   190             lang = self.vreg.property_value('ui.language')
       
   191             recipients = zip(recipients, repeat(lang))
       
   192         entity = self.entity(0, 0)
       
   193         # if the view is using timestamp in message ids, no way to reference
       
   194         # previous email
       
   195         if not self.msgid_timestamp:
       
   196             refs = [self.construct_message_id(eid)
       
   197                     for eid in entity.notification_references(self)]
       
   198         else:
       
   199             refs = ()
       
   200         msgid = self.construct_message_id(entity.eid)
       
   201         userdata = self.req.user_data()
       
   202         origlang = self.req.lang
       
   203         for emailaddr, lang in recipients:
       
   204             self.req.set_language(lang)
       
   205             # since the same view (eg self) may be called multiple time and we
       
   206             # need a fresh stream at each iteration, reset it explicitly
       
   207             self.w = None
       
   208             # call dispatch before subject to set .row/.col attributes on the view :/
       
   209             content = self.dispatch(row=0, col=0, **kwargs)
       
   210             subject = self.subject()
       
   211             msg = format_mail(userdata, [emailaddr], content, subject,
       
   212                               config=self.config, msgid=msgid, references=refs)
       
   213             self.send([emailaddr], msg)
       
   214         # restore language
       
   215         self.req.set_language(origlang)
       
   216 
       
   217     def send(self, recipients, msg):
       
   218         SendMailOp(self.req, recipients=recipients, msg=msg)
       
   219 
       
   220 
       
   221 def construct_message_id(appid, eid, withtimestamp=True):
       
   222     if withtimestamp:
       
   223         addrpart = 'eid=%s&timestamp=%.10f' % (eid, time())
       
   224     else:
       
   225         addrpart = 'eid=%s' % eid
       
   226     # we don't want any equal sign nor trailing newlines
       
   227     leftpart = b64encode(addrpart, '.-').rstrip().rstrip('=')
       
   228     return '<%s@%s.%s>' % (leftpart, appid, gethostname())
       
   229 
       
   230 
       
   231 def parse_message_id(msgid, appid):
       
   232     if msgid[0] == '<':
       
   233         msgid = msgid[1:]
       
   234     if msgid[-1] == '>':
       
   235         msgid = msgid[:-1]
       
   236     try:
       
   237         values, qualif = msgid.split('@')
       
   238         padding = len(values) % 4
       
   239         values = b64decode(str(values + '='*padding), '.-')
       
   240         values = dict(v.split('=') for v in values.split('&'))
       
   241         fromappid, host = qualif.split('.', 1)
       
   242     except:
       
   243         return None
       
   244     if appid != fromappid or host != gethostname():
       
   245         return None
       
   246     return values
       
   247     
       
   248 
       
   249 class StatusChangeMixIn(object):
       
   250     id = 'notif_status_change'
       
   251     msgid_timestamp = True
       
   252     message = _('status changed')
       
   253     content = _("""
       
   254 %(user)s changed status from <%(previous_state)s> to <%(current_state)s> for entity
       
   255 '%(title)s'
       
   256 
       
   257 %(comment)s
       
   258 
       
   259 url: %(url)s
       
   260 """)
       
   261 
       
   262 
       
   263 class ContentAddedMixIn(object):
       
   264     """define emailcontent view for entity types for which you want to be notified
       
   265     """
       
   266     id = 'notif_after_add_entity' 
       
   267     msgid_timestamp = False
       
   268     message = _('new')
       
   269     content = """
       
   270 %(title)s
       
   271 
       
   272 %(content)s
       
   273 
       
   274 url: %(url)s
       
   275 """
       
   276 
       
   277 ###############################################################################
       
   278 # Actual notification views.                                                  #
       
   279 #                                                                             #
       
   280 # disable them at the recipients_finder level if you don't want them          #
       
   281 ###############################################################################
       
   282 
       
   283 # XXX should be based on dc_title/dc_description, no?
       
   284 
       
   285 class NormalizedTextView(ContentAddedMixIn, NotificationView):
       
   286     def context(self, **kwargs):
       
   287         entity = self.entity(0, 0)
       
   288         content = entity.printable_value(self.content_attr, format='text/plain')
       
   289         if content:
       
   290             contentformat = getattr(entity, self.content_attr + '_format', 'text/rest')
       
   291             content = normalize_text(content, 80, rest=contentformat=='text/rest')
       
   292         return super(NormalizedTextView, self).context(content=content, **kwargs)
       
   293     
       
   294     def subject(self):
       
   295         entity = self.entity(0, 0)
       
   296         return  u'%s #%s (%s)' % (self.req.__('New %s' % entity.e_schema),
       
   297                                   entity.eid, self.user_login())
       
   298 
       
   299 
       
   300 class CardAddedView(NormalizedTextView):
       
   301     """get notified from new cards"""
       
   302     accepts = ('Card',)
       
   303     content_attr = 'synopsis'
       
   304     
       
   305