[MERGE] lp:~openerp-dev/openobject-addons/trunk-review_module_desc-psi
[odoo/odoo.git] / addons / mail / mail_thread.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
19 #
20 ##############################################################################
21
22 import base64
23 import email
24 from email.utils import parsedate
25 import logging
26 from mail_message import decode, to_email
27 from operator import itemgetter
28 from osv import osv, fields
29 import re
30 import time
31 import tools
32 from tools.translate import _
33 import xmlrpclib
34
35 _logger = logging.getLogger(__name__)
36
37 class mail_thread(osv.Model):
38     '''Mixin model, meant to be inherited by any model that needs to
39        act as a discussion topic on which messages can be attached.
40        Public methods are prefixed with ``message_`` in order to avoid
41        name collisions with methods of the models that will inherit
42        from this mixin.
43
44        ``mail.thread`` is designed to work without adding any field
45        to the extended models. All functionalities and expected behavior
46        are managed by mail.thread, using model name and record ids.
47        A widget has been designed for the 6.1 and following version of OpenERP
48        web-client. However, due to technical limitations, ``mail.thread``
49        adds a simulated one2many field, to display the web widget by
50        overriding the default field displayed. Using this field
51        is not recommanded has it will disappeear in future version
52        of OpenERP, leading to a pure mixin class.
53
54        Inheriting classes are not required to implement any method, as the
55        default implementation will work for any model. However it is common
56        to override at least the ``message_new`` and ``message_update``
57        methods (calling ``super``) to add model-specific behavior at
58        creation and update of a thread; and ``message_get_subscribers``
59        to manage more precisely the social aspect of the thread through
60        the followers.
61     '''
62     _name = 'mail.thread'
63     _description = 'Email Thread'
64
65     def _get_message_ids(self, cr, uid, ids, name, args, context=None):
66         res = {}
67         for id in ids:
68             message_ids = self.message_search(cr, uid, [id], context=context)
69             subscriber_ids = self.message_get_subscribers(cr, uid, [id], context=context)
70             res[id] = {
71                 'message_ids': message_ids,
72                 'message_summary': "<span><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (len(message_ids), len(subscriber_ids)),
73             }
74         return res
75
76     def _search_message_ids(self, cr, uid, obj, name, args, context=None):
77         msg_obj = self.pool.get('mail.message')
78         msg_ids = msg_obj.search(cr, uid, ['&', ('res_id', 'in', args[0][2]), ('model', '=', self._name)], context=context)
79         return [('id', 'in', msg_ids)]
80
81     _columns = {
82         'message_ids': fields.function(_get_message_ids, method=True,
83                         fnct_search=_search_message_ids,
84             type='one2many', obj='mail.message', _fields_id = 'res_id',
85             string='Temp messages', multi="_get_message_ids",
86             help="Functional field holding messages related to the current document."),
87         'message_state': fields.boolean('Read',
88             help="When checked, new messages require your attention."),
89         'message_summary': fields.function(_get_message_ids, method=True,
90             type='text', string='Summary', multi="_get_message_ids",
91             help="Holds the Chatter summary (number of messages, ...). "\
92                  "This summary is directly in html format in order to "\
93                  "be inserted in kanban views."),
94     }
95     
96     _defaults = {
97         'message_state': True,
98     }
99
100     #------------------------------------------------------
101     # Automatic subscription when creating/reading
102     #------------------------------------------------------
103
104     def create(self, cr, uid, vals, context=None):
105         """Automatically subscribe the creator """
106         thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
107         if thread_id:
108             self.message_subscribe(cr, uid, [thread_id], [uid], context=context)
109         return thread_id
110
111     def write(self, cr, uid, ids, vals, context=None):
112         """Automatically subscribe the writer"""
113         if isinstance(ids, (int, long)):
114             ids = [ids]
115         write_res = super(mail_thread, self).write(cr, uid, ids, vals, context=context);
116         if write_res:
117             self.message_subscribe(cr, uid, ids, [uid], context=context)
118         return write_res;
119
120     def unlink(self, cr, uid, ids, context=None):
121         """Override unlink, to automatically delete
122            - subscriptions
123            - messages
124            that are linked with res_model and res_id, not through
125            a foreign key with a 'cascade' ondelete attribute.
126            Notifications will be deleted with messages
127         """
128         subscr_obj = self.pool.get('mail.subscription')
129         msg_obj = self.pool.get('mail.message')
130         # delete subscriptions
131         subscr_to_del_ids = subscr_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
132         subscr_obj.unlink(cr, uid, subscr_to_del_ids, context=context)
133         # delete messages and notifications
134         msg_to_del_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
135         msg_obj.unlink(cr, uid, msg_to_del_ids, context=context)
136
137         return super(mail_thread, self).unlink(cr, uid, ids, context=context)
138
139     #------------------------------------------------------
140     # mail.message wrappers and tools
141     #------------------------------------------------------
142
143     def message_create(self, cr, uid, thread_id, vals, context=None):
144         """ OpenChatter: wrapper of mail.message create method
145            - creates the mail.message
146            - automatically subscribe the message writer
147            - push the message to subscribed users
148         """
149         if context is None:
150             context = {}
151         
152         message_obj = self.pool.get('mail.message')
153         notification_obj = self.pool.get('mail.notification')
154         body = vals.get('body_html', '') if vals.get('content_subtype') == 'html' else vals.get('body_text', '')
155         
156         # automatically subscribe the writer of the message
157         if vals['user_id']:
158             self.message_subscribe(cr, uid, [thread_id], [vals['user_id']], context=context)
159         
160         # create message
161         msg_id = message_obj.create(cr, uid, vals, context=context)
162         
163         # Set as unread if writer is not the document responsible
164         self.message_create_set_unread(cr, uid, [thread_id], context=context)
165         
166         # special: if install mode, do not push demo data
167         if context.get('install_mode', False):
168             return msg_id
169         
170         # get users that will get a notification pushed
171         user_to_push_ids = self.message_get_user_ids_to_notify(cr, uid, [thread_id], vals, context=context)
172         for id in user_to_push_ids:
173             notification_obj.create(cr, uid, {'user_id': id, 'message_id': msg_id}, context=context)
174         
175         # create the email to send
176         email_id = self.message_create_notify_by_email(cr, uid, vals, user_to_push_ids, context=context)
177         
178         return msg_id
179     
180     def message_get_user_ids_to_notify(self, cr, uid, thread_ids, new_msg_vals, context=None):
181         subscription_obj = self.pool.get('mail.subscription')
182         # get body
183         body = new_msg_vals.get('body_html', '') if new_msg_vals.get('content_subtype') == 'html' else new_msg_vals.get('body_text', '')
184         
185         # get subscribers
186         notif_user_ids = self.message_get_subscribers(cr, uid, thread_ids, context=context)
187         
188         # add users requested via parsing message (@login)
189         notif_user_ids += self.message_parse_users(cr, uid, body, context=context)
190         
191         # add users requested to perform an action (need_action mechanism)
192         if hasattr(self, 'get_needaction_user_ids'):
193             user_ids_dict = self.get_needaction_user_ids(cr, uid, thread_ids, context=context)
194             for id, user_ids in user_ids_dict.iteritems():
195                 notif_user_ids += user_ids
196         
197         # add users notified of the parent messages (because: if parent message contains @login, login must receive the replies)
198         if new_msg_vals.get('parent_id'):
199             notif_obj = self.pool.get('mail.notification')
200             parent_notif_ids = notif_obj.search(cr, uid, [('message_id', '=', new_msg_vals.get('parent_id'))], context=context)
201             parent_notifs = notif_obj.read(cr, uid, parent_notif_ids, context=context)
202             notif_user_ids += [parent_notif['user_id'][0] for parent_notif in parent_notifs]
203
204         # remove duplicate entries
205         notif_user_ids = list(set(notif_user_ids))
206         return notif_user_ids
207
208     def message_parse_users(self, cr, uid, string, context=None):
209         """Parse message content
210            - if find @login -(^|\s)@((\w|@|\.)*)-: returns the related ids
211              this supports login that are emails (such as @raoul@grobedon.net)
212         """
213         regex = re.compile('(^|\s)@((\w|@|\.)*)')
214         login_lst = [item[1] for item in regex.findall(string)]
215         if not login_lst: return []
216         user_ids = self.pool.get('res.users').search(cr, uid, [('login', 'in', login_lst)], context=context)
217         return user_ids
218
219     #------------------------------------------------------
220     # Generic message api
221     #------------------------------------------------------
222
223     def message_capable_models(self, cr, uid, context=None):
224         ret_dict = {}
225         for model_name in self.pool.obj_list():
226             model = self.pool.get(model_name)
227             if 'mail.thread' in getattr(model, '_inherit', []):
228                 ret_dict[model_name] = model._description
229         return ret_dict
230
231     def message_append(self, cr, uid, threads, subject, body_text=None, body_html=None,
232                         type='email', email_date=None, parent_id=False,
233                         content_subtype='plain', state=None,
234                         partner_ids=None, email_from=False, email_to=False,
235                         email_cc=None, email_bcc=None, reply_to=None,
236                         headers=None, message_id=False, references=None,
237                         attachments=None, original=None, context=None):
238         """ Creates a new mail.message through message_create. The new message
239             is attached to the current mail.thread, containing all the details 
240             passed as parameters. All attachments will be attached to the 
241             thread record as well as to the actual message.
242            
243             This method calls message_create that will handle management of
244             subscription and notifications, and effectively create the message.
245            
246             If ``email_from`` is not set or ``type`` not set as 'email',
247             a note message is created (comment or system notification), 
248             without the usual envelope attributes (sender, recipients, etc.).
249
250             :param threads: list of thread ids, or list of browse_records
251                 representing threads to which a new message should be attached
252             :param subject: subject of the message, or description of the event;
253                 this is totally optional as subjects are not important except
254                 for specific messages (blog post, job offers) or for emails
255             :param body_text: plaintext contents of the mail or log message
256             :param body_html: html contents of the mail or log message
257             :param type: type of message: 'email', 'comment', 'notification';
258                 email by default
259             :param email_date: email date string if different from now, in
260                 server timezone
261             :param parent_id: id of the parent message (threaded messaging model)
262             :param content_subtype: optional content_subtype of message: 'plain'
263                 or 'html', corresponding to the main body contents (body_text or
264                 body_html).
265             :param state: state of message
266             :param partner_ids: destination partners of the message, in addition
267                 to the now fully optional email_to; this method is supposed to
268                 received a list of ids is not None. The specific many2many
269                 instruction will be generated by this method.
270             :param email_from: Email From / Sender address if any
271             :param email_to: Email-To / Recipient address
272             :param email_cc: Comma-Separated list of Carbon Copy Emails To
273                 addresses if any
274             :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To
275                 addresses if any
276             :param reply_to: reply_to header
277             :param headers: mail headers to store
278             :param message_id: optional email identifier
279             :param references: optional email references
280             :param dict attachments: map of attachment filenames to binary
281                 contents, if any.
282             :param str original: optional full source of the RFC2822 email, for
283                 reference
284             :param dict context: if a ``thread_model`` value is present in the
285                 context, its value will be used to determine the model of the
286                 thread to update (instead of the current model).
287         """
288         if context is None:
289             context = {}
290         if attachments is None:
291             attachments = {}
292
293         if email_date:
294             edate = parsedate(email_date)
295             if edate is not None:
296                 email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate)
297
298         if all(isinstance(thread_id, (int, long)) for thread_id in threads):
299             model = context.get('thread_model') or self._name
300             model_pool = self.pool.get(model)
301             threads = model_pool.browse(cr, uid, threads, context=context)
302
303         ir_attachment = self.pool.get('ir.attachment')
304         mail_message = self.pool.get('mail.message')
305
306         new_msg_ids = []
307         for thread in threads:
308             to_attach = []
309             for attachment in attachments:
310                 fname, fcontent = attachment
311                 if isinstance(fcontent, unicode):
312                     fcontent = fcontent.encode('utf-8')
313                 data_attach = {
314                     'name': fname,
315                     'datas': base64.b64encode(str(fcontent)),
316                     'datas_fname': fname,
317                     'description': _('Mail attachment'),
318                     'res_model': thread._name,
319                     'res_id': thread.id,
320                 }
321                 to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context))
322             # find related partner: partner_id column in thread object, or self is res.partner model
323             partner_id = ('partner_id' in thread._columns.keys()) and (thread.partner_id and thread.partner_id.id or False) or False
324             if not partner_id and thread._name == 'res.partner':
325                 partner_id = thread.id
326             # destination partners
327             if partner_ids is None:
328                 partner_ids = []
329             mail_partner_ids = [(6, 0, partner_ids)]
330
331             data = {
332                 'subject': subject,
333                 'body_text': body_text or (hasattr(thread, 'description') and thread.description or ''),
334                 'body_html': body_html or '',
335                 'parent_id': parent_id,
336                 'date': email_date or fields.datetime.now(),
337                 'type': type,
338                 'content_subtype': content_subtype,
339                 'state': state,
340                 'message_id': message_id,
341                 'partner_ids': mail_partner_ids,
342                 'attachment_ids': [(6, 0, to_attach)],
343                 'user_id': uid,
344                 'model' : thread._name,
345                 'res_id': thread.id,
346                 'partner_id': partner_id,
347             }
348
349             if email_from or type == 'email':
350                 for param in (email_to, email_cc, email_bcc):
351                     if isinstance(param, list):
352                         param = ", ".join(param)
353                 data.update({
354                     'email_to': email_to,
355                     'email_from': email_from or \
356                         (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.user_email),
357                     'email_cc': email_cc,
358                     'email_bcc': email_bcc,
359                     'references': references,
360                     'headers': headers,
361                     'reply_to': reply_to,
362                     'original': original, })
363
364             new_msg_ids.append(self.message_create(cr, uid, thread.id, data, context=context))
365         return new_msg_ids
366
367     def message_append_dict(self, cr, uid, ids, msg_dict, context=None):
368         """Creates a new mail.message attached to the given threads (``ids``),
369            with the contents of ``msg_dict``, by calling ``message_append``
370            with the mail details. All attachments in msg_dict will be
371            attached to the object record as well as to the actual
372            mail message.
373
374            :param dict msg_dict: a map containing the email details and
375                                  attachments. See ``message_process()`` and
376                                 ``mail.message.parse()`` for details on
377                                 the dict structure.
378            :param dict context: if a ``thread_model`` value is present
379                                 in the context, its value will be used
380                                 to determine the model of the thread to
381                                 update (instead of the current model).
382         """
383         return self.message_append(cr, uid, ids,
384                             subject = msg_dict.get('subject'),
385                             body_text = msg_dict.get('body_text'),
386                             body_html= msg_dict.get('body_html'),
387                             parent_id = msg_dict.get('parent_id', False),
388                             type = msg_dict.get('type', 'email'),
389                             content_subtype = msg_dict.get('content_subtype'),
390                             state = msg_dict.get('state'),
391                             partner_ids = msg_dict.get('partner_ids'),
392                             email_from = msg_dict.get('from', msg_dict.get('email_from')),
393                             email_to = msg_dict.get('to', msg_dict.get('email_to')),
394                             email_cc = msg_dict.get('cc', msg_dict.get('email_cc')),
395                             email_bcc = msg_dict.get('bcc', msg_dict.get('email_bcc')),
396                             reply_to = msg_dict.get('reply', msg_dict.get('reply_to')),
397                             email_date = msg_dict.get('date'),
398                             message_id = msg_dict.get('message-id', msg_dict.get('message_id')),
399                             references = msg_dict.get('references')\
400                                       or msg_dict.get('in-reply-to'),
401                             attachments = msg_dict.get('attachments'),
402                             headers = msg_dict.get('headers'),
403                             original = msg_dict.get('original'),
404                             context = context)
405
406     #------------------------------------------------------
407     # Message loading
408     #------------------------------------------------------
409
410     def _message_search_ancestor_ids(self, cr, uid, ids, child_ids, ancestor_ids, context=None):
411         """ Given message child_ids ids, find their ancestors until ancestor_ids
412             using their parent_id relationship.
413
414             :param child_ids: the first nodes of the search
415             :param ancestor_ids: list of ancestors. When the search reach an
416                                  ancestor, it stops.
417         """
418         def _get_parent_ids(message_list, ancestor_ids, child_ids):
419             """ Tool function: return the list of parent_ids of messages
420                 contained in message_list. Parents that are in ancestor_ids
421                 or in child_ids are not returned. """
422             return [message['parent_id'][0] for message in message_list
423                         if message['parent_id']
424                         and message['parent_id'][0] not in ancestor_ids
425                         and message['parent_id'][0] not in child_ids
426                     ]
427
428         message_obj = self.pool.get('mail.message')
429         messages_temp = message_obj.read(cr, uid, child_ids, ['id', 'parent_id'], context=context)
430         parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
431         child_ids += parent_ids
432         cur_iter = 0; max_iter = 100; # avoid infinite loop
433         while (parent_ids and (cur_iter < max_iter)):
434             cur_iter += 1
435             messages_temp = message_obj.read(cr, uid, parent_ids, ['id', 'parent_id'], context=context)
436             parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
437             child_ids += parent_ids
438         if (cur_iter > max_iter):
439             _logger.warning("Possible infinite loop in _message_search_ancestor_ids. "\
440                 "Note that this algorithm is intended to check for cycle in "\
441                 "message graph, leading to a curious error. Have fun.")
442         return child_ids
443
444     def message_search_get_domain(self, cr, uid, ids, context=None):
445         """ OpenChatter feature: get the domain to search the messages related
446             to a document. mail.thread defines the default behavior as
447             being messages with model = self._name, id in ids.
448             This method should be overridden if a model has to implement a
449             particular behavior.
450         """
451         return ['&', ('res_id', 'in', ids), ('model', '=', self._name)]
452
453     def message_search(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None, 
454                         limit=100, offset=0, domain=None, count=False, context=None):
455         """ OpenChatter feature: return thread messages ids according to the
456             search domain given by ``message_search_get_domain``.
457             
458             It is possible to add in the search the parent of messages by
459             setting the fetch_ancestors flag to True. In that case, using
460             the parent_id relationship, the method returns the id list according
461             to the search domain, but then calls ``_message_search_ancestor_ids``
462             that will add to the list the ancestors ids. The search is limited
463             to parent messages having an id in ancestor_ids or having
464             parent_id set to False.
465             
466             If ``count==True``, the number of ids is returned instead of the
467             id list. The count is done by hand instead of passing it as an 
468             argument to the search call because we might want to perform
469             a research including parent messages until some ancestor_ids.
470             
471             :param fetch_ancestors: performs an ascended search; will add 
472                                     to fetched msgs all their parents until
473                                     ancestor_ids
474             :param ancestor_ids: used when fetching ancestors
475             :param domain: domain to add to the search; especially child_of
476                            is interesting when dealing with threaded display.
477                            Note that the added domain is anded with the 
478                            default domain.
479             :param limit, offset, count, context: as usual
480         """
481         search_domain = self.message_search_get_domain(cr, uid, ids, context=context)
482         if domain:
483             search_domain += domain
484         message_obj = self.pool.get('mail.message')
485         message_res = message_obj.search(cr, uid, search_domain, limit=limit, offset=offset, count=count, context=context)
486         if not count and fetch_ancestors:
487             message_res += self._message_search_ancestor_ids(cr, uid, ids, message_res, ancestor_ids, context=context) 
488         return message_res
489
490     def message_read(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None, 
491                         limit=100, offset=0, domain=None, context=None):
492         """ OpenChatter feature: read the messages related to some threads.
493             This method is used mainly the Chatter widget, to directly have
494             read result instead of searching then reading.
495
496             Please see message_search for more information about the parameters.
497         """
498         message_ids = self.message_search(cr, uid, ids, fetch_ancestors, ancestor_ids,
499             limit, offset, domain, context=context)
500         messages = self.pool.get('mail.message').read(cr, uid, message_ids, context=context)
501
502         """ Retrieve all attachments names """
503         map_id_to_name = dict((attachment_id, '') for message in messages for attachment_id in message['attachment_ids'])
504         map_id_to_name = {}
505         for msg in messages:
506             for attach_id in msg["attachment_ids"]:
507                 map_id_to_name[attach_id] = '' # use empty string as a placeholder
508
509         ids = map_id_to_name.keys()
510         names = self.pool.get('ir.attachment').name_get(cr, uid, ids, context=context)
511         
512         # convert the list of tuples into a dictionnary
513         for name in names: 
514             map_id_to_name[name[0]] = name[1]
515         
516         # give corresponding ids and names to each message
517         for msg in messages:
518             msg["attachments"] = []
519             
520             for attach_id in msg["attachment_ids"]:
521                 msg["attachments"].append({'id': attach_id, 'name': map_id_to_name[attach_id]})
522         
523         # Set the threads as read
524         self.message_check_and_set_read(cr, uid, ids, context=context)
525         # Sort and return the messages
526         messages = sorted(messages, key=lambda d: (-d['id']))
527         return messages
528
529     def message_get_pushed_messages(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
530                             limit=100, offset=0, msg_search_domain=[], context=None):
531         """ OpenChatter: wall: get the pushed notifications and used them
532             to fetch messages to display on the wall.
533             
534             :param fetch_ancestors: performs an ascended search; will add
535                                     to fetched msgs all their parents until
536                                     ancestor_ids
537             :param ancestor_ids: used when fetching ancestors
538             :param domain: domain to add to the search; especially child_of
539                            is interesting when dealing with threaded display
540             :param ascent: performs an ascended search; will add to fetched msgs
541                            all their parents until root_ids
542             :param root_ids: for ascent search
543             :return: list of mail.messages sorted by date
544         """
545         notification_obj = self.pool.get('mail.notification')
546         msg_obj = self.pool.get('mail.message')
547         # update message search
548         for arg in msg_search_domain:
549             if isinstance(arg, (tuple, list)):
550                 arg[0] = 'message_id.' + arg[0]
551         # compose final domain
552         domain = [('user_id', '=', uid)] + msg_search_domain
553         # get notifications
554         notification_ids = notification_obj.search(cr, uid, domain, limit=limit, offset=offset, context=context)
555         notifications = notification_obj.browse(cr, uid, notification_ids, context=context)
556         msg_ids = [notification.message_id.id for notification in notifications]
557         # get messages
558         msg_ids = msg_obj.search(cr, uid, [('id', 'in', msg_ids)], context=context)
559         if (fetch_ancestors): msg_ids = self._message_search_ancestor_ids(cr, uid, ids, msg_ids, ancestor_ids, context=context)
560         msgs = msg_obj.read(cr, uid, msg_ids, context=context)
561         return msgs
562
563     #------------------------------------------------------
564     # Mail gateway
565     #------------------------------------------------------
566     # message_process will call either message_new or message_update.
567
568     def message_process(self, cr, uid, model, message, custom_values=None,
569                         save_original=False, strip_attachments=False,
570                         context=None):
571         """Process an incoming RFC2822 email message related to the
572            given thread model, relying on ``mail.message.parse()``
573            for the parsing operation, and then calling ``message_new``
574            (if the thread record did not exist) or ``message_update``
575            (if it did), then calling ``message_forward`` to automatically
576            notify other people that should receive this message.
577
578            :param string model: the thread model for which a new message
579                                 must be processed
580            :param message: source of the RFC2822 mail
581            :type message: string or xmlrpclib.Binary
582            :type dict custom_values: optional dictionary of field values
583                                     to pass to ``message_new`` if a new
584                                     record needs to be created. Ignored
585                                     if the thread record already exists.
586            :param bool save_original: whether to keep a copy of the original
587                 email source attached to the message after it is imported.
588            :param bool strip_attachments: whether to strip all attachments
589                 before processing the message, in order to save some space.
590         """
591         # extract message bytes - we are forced to pass the message as binary because
592         # we don't know its encoding until we parse its headers and hence can't
593         # convert it to utf-8 for transport between the mailgate script and here.
594         if isinstance(message, xmlrpclib.Binary):
595             message = str(message.data)
596
597         model_pool = self.pool.get(model)
598         if self._name != model:
599             if context is None: context = {}
600             context.update({'thread_model': model})
601
602         mail_message = self.pool.get('mail.message')
603
604         # Parse Message
605         # Warning: message_from_string doesn't always work correctly on unicode,
606         # we must use utf-8 strings here :-(
607         if isinstance(message, unicode):
608             message = message.encode('utf-8')
609         msg_txt = email.message_from_string(message)
610         msg = mail_message.parse_message(msg_txt, save_original=save_original, context=context)
611
612         # update state
613         msg['state'] = 'received'
614         
615         if strip_attachments and 'attachments' in msg:
616             del msg['attachments']
617
618         # Create New Record into particular model
619         def create_record(msg):
620             if hasattr(model_pool, 'message_new'):
621                 return model_pool.message_new(cr, uid, msg,
622                                               custom_values,
623                                               context=context)
624         res_id = False
625         if msg.get('references') or msg.get('in-reply-to'):
626             references = msg.get('references') or msg.get('in-reply-to')
627             if '\r\n' in references:
628                 references = references.split('\r\n')
629             else:
630                 references = references.split(' ')
631             for ref in references:
632                 ref = ref.strip()
633                 res_id = tools.reference_re.search(ref)
634                 if res_id:
635                     res_id = res_id.group(1)
636                 else:
637                     res_id = tools.res_re.search(msg['subject'])
638                     if res_id:
639                         res_id = res_id.group(1)
640                 if res_id:
641                     res_id = res_id
642                     if model_pool.exists(cr, uid, res_id):
643                         if hasattr(model_pool, 'message_update'):
644                             model_pool.message_update(cr, uid, [res_id], msg, {}, context=context)
645                     else:
646                         # referenced thread was not found, we'll have to create a new one
647                         res_id = False
648         if not res_id:
649             res_id = create_record(msg)
650         # To forward the email to other followers
651         self.message_forward(cr, uid, model, [res_id], msg_txt, context=context)
652         # Set as Unread
653         model_pool.message_mark_as_unread(cr, uid, [res_id], context=context)
654         return res_id
655
656     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
657         """Called by ``message_process`` when a new message is received
658            for a given thread model, if the message did not belong to
659            an existing thread.
660            The default behavior is to create a new record of the corresponding
661            model (based on some very basic info extracted from the message),
662            then attach the message to the newly created record
663            (by calling ``message_append_dict``).
664            Additional behavior may be implemented by overriding this method.
665
666            :param dict msg_dict: a map containing the email details and
667                                  attachments. See ``message_process`` and
668                                 ``mail.message.parse`` for details.
669            :param dict custom_values: optional dictionary of additional
670                                       field values to pass to create()
671                                       when creating the new thread record.
672                                       Be careful, these values may override
673                                       any other values coming from the message.
674            :param dict context: if a ``thread_model`` value is present
675                                 in the context, its value will be used
676                                 to determine the model of the record
677                                 to create (instead of the current model).
678            :rtype: int
679            :return: the id of the newly created thread object
680         """
681         if context is None:
682             context = {}
683         model = context.get('thread_model') or self._name
684         model_pool = self.pool.get(model)
685         fields = model_pool.fields_get(cr, uid, context=context)
686         data = model_pool.default_get(cr, uid, fields, context=context)
687         if 'name' in fields and not data.get('name'):
688             data['name'] = msg_dict.get('from', '')
689         if custom_values and isinstance(custom_values, dict):
690             data.update(custom_values)
691         res_id = model_pool.create(cr, uid, data, context=context)
692         self.message_append_dict(cr, uid, [res_id], msg_dict, context=context)
693         return res_id
694
695     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
696         """ Called by ``message_process`` when a new message is received
697             for an existing thread. The default behavior is to create a
698             new mail.message in the given thread (by calling
699             ``message_append_dict``)
700             Additional behavior may be implemented by overriding this
701             method.
702
703             :param dict msg_dict: a map containing the email details and
704                                 attachments. See ``message_process`` and
705                                 ``mail.message.parse()`` for details.
706             :param dict vals: a dict containing values to update records
707                               given their ids; if the dict is None or is
708                               void, no write operation is performed.
709             :param dict context: if a ``thread_model`` value is present
710                                 in the context, its value will be used
711                                 to determine the model of the thread to
712                                 update (instead of the current model).
713         """
714         if update_vals:
715             self.write(cr, uid, ids, update_vals, context=context)
716         return self.message_append_dict(cr, uid, ids, msg_dict, context=context)
717
718     def message_thread_followers(self, cr, uid, ids, context=None):
719         """ Returns a list of email addresses of the people following
720             this thread, including the sender of each mail, and the
721             people who were in CC of the messages, if any.
722         """
723         res = {}
724         if isinstance(ids, (str, int, long)):
725             ids = [long(ids)]
726         for thread in self.browse(cr, uid, ids, context=context):
727             l = set()
728             for message in thread.message_ids:
729                 l.add((message.user_id and message.user_id.user_email) or '')
730                 l.add(message.email_from or '')
731                 l.add(message.email_cc or '')
732             res[thread.id] = filter(None, l)
733         return res
734
735     def message_forward(self, cr, uid, model, thread_ids, msg, email_error=False, context=None):
736         """Sends an email to all people following the given threads.
737            The emails are forwarded immediately, not queued for sending,
738            and not archived.
739
740         :param str model: thread model
741         :param list thread_ids: ids of the thread records
742         :param msg: email.message.Message object to forward
743         :param email_error: optional email address to notify in case
744                             of any delivery error during the forward.
745         :return: True
746         """
747         model_pool = self.pool.get(model)
748         smtp_server_obj = self.pool.get('ir.mail_server')
749         mail_message = self.pool.get('mail.message')
750         for res in model_pool.browse(cr, uid, thread_ids, context=context):
751             if hasattr(model_pool, 'message_thread_followers'):
752                 followers = model_pool.message_thread_followers(cr, uid, [res.id])[res.id]
753             else:
754                 followers = self.message_thread_followers(cr, uid, [res.id])[res.id]
755             message_followers_emails = to_email(','.join(filter(None, followers)))
756             message_recipients = to_email(','.join(filter(None,
757                                                                        [decode(msg['from']),
758                                                                         decode(msg['to']),
759                                                                         decode(msg['cc'])])))
760             forward_to = [i for i in message_followers_emails if (i and (i not in message_recipients))]
761             if forward_to:
762                 # TODO: we need an interface for this for all types of objects, not just leads
763                 if hasattr(res, 'section_id'):
764                     del msg['reply-to']
765                     msg['reply-to'] = res.section_id.reply_to
766
767                 smtp_from, = to_email(msg['from'])
768                 msg['from'] = smtp_from
769                 msg['to'] =  ", ".join(forward_to)
770                 msg['message-id'] = tools.generate_tracking_message_id(res.id)
771                 if not smtp_server_obj.send_email(cr, uid, msg) and email_error:
772                     subj = msg['subject']
773                     del msg['subject'], msg['to'], msg['cc'], msg['bcc']
774                     msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj
775                     msg['to'] = email_error
776                     smtp_server_obj.send_email(cr, uid, msg)
777         return True
778
779     def message_partner_by_email(self, cr, uid, email, context=None):
780         """Attempts to return the id of a partner address matching
781            the given ``email``, and the corresponding partner id.
782            Can be used by classes using the ``mail.thread`` mixin
783            to lookup the partner and use it in their implementation
784            of ``message_new`` to link the new record with a
785            corresponding partner.
786            The keys used in the returned dict are meant to map
787            to usual names for relationships towards a partner
788            and one of its addresses.
789
790            :param email: email address for which a partner
791                          should be searched for.
792            :rtype: dict
793            :return: a map of the following form::
794
795                       { 'partner_address_id': id or False,
796                         'partner_id': pid or False }
797         """
798         partner_pool = self.pool.get('res.partner')
799         res = {'partner_id': False}
800         if email:
801             email = to_email(email)[0]
802             contact_ids = partner_pool.search(cr, uid, [('email', '=', email)])
803             if contact_ids:
804                 contact = partner_pool.browse(cr, uid, contact_ids[0])
805                 res['partner_id'] = contact.id
806         return res
807
808     # for backwards-compatibility with old scripts
809     process_email = message_process
810
811     #------------------------------------------------------
812     # Note specific
813     #------------------------------------------------------
814
815     def log(self, cr, uid, id, message, secondary=False, context=None):
816         _logger.warning("log() is deprecated. As this module inherit from \
817                         mail.thread, the message will be managed by this \
818                         module instead of by the res.log mechanism. Please \
819                         use the mail.thread OpenChatter API instead of the \
820                         now deprecated res.log.")
821         self.message_append_note(cr, uid, [id], 'res.log', message, context=context)
822
823     def message_append_note(self, cr, uid, ids, subject=None, body=None, parent_id=False,
824                             type='notification', content_subtype='html', context=None):
825         if type in ['notification', 'comment']:
826             subject = None
827         if content_subtype == 'html':
828             body_html = body
829             body_text = body
830         else:
831             body_html = body
832             body_text = body
833         return self.message_append(cr, uid, ids, subject, body_html, body_text,
834                                     type, parent_id=parent_id,
835                                     content_subtype=content_subtype, context=context)
836
837     #------------------------------------------------------
838     # Subscription mechanism
839     #------------------------------------------------------
840
841     def message_get_subscribers(self, cr, uid, ids, context=None):
842         """ Returns the current document followers. Basically this method
843             checks in mail.subscription for entries with matching res_model,
844             res_id.
845             This method can be overriden to add implicit subscribers, such
846             as project managers, by adding their user_id to the list of
847             ids returned by this method.
848         """
849         subscr_obj = self.pool.get('mail.subscription')
850         subscr_ids = subscr_obj.search(cr, uid, ['&', ('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
851         return [sub['user_id'][0] for sub in subscr_obj.read(cr, uid, subscr_ids, ['user_id'], context=context)]
852
853     def message_read_subscribers(self, cr, uid, ids, fields=['id', 'name', 'avatar'], context=None):
854         """ Returns the current document followers as a read result. Used
855             mainly for Chatter having only one method to call to have
856             details about users.
857         """
858         user_ids = self.message_get_subscribers(cr, uid, ids, context=context)
859         return self.pool.get('res.users').read(cr, uid, user_ids, fields=fields, context=context)
860
861     def message_is_subscriber(self, cr, uid, ids, user_id = None, context=None):
862         """ Check if uid or user_id (if set) is a subscriber to the current
863             document.
864             
865             :param user_id: if set, check is done on user_id; if not set
866                             check is done on uid
867         """
868         sub_user_id = uid if user_id is None else user_id
869         if sub_user_id in self.message_get_subscribers(cr, uid, ids, context=context):
870             return True
871         return False
872
873     def message_subscribe(self, cr, uid, ids, user_ids = None, context=None):
874         """ Subscribe the user (or user_ids) to the current document.
875             
876             :param user_ids: a list of user_ids; if not set, subscribe
877                              uid instead
878         """
879         subscription_obj = self.pool.get('mail.subscription')
880         to_subscribe_uids = [uid] if user_ids is None else user_ids
881         create_ids = []
882         for id in ids:
883             already_subscribed_user_ids = self.message_get_subscribers(cr, uid, [id], context=context)
884             for user_id in to_subscribe_uids:
885                 if user_id in already_subscribed_user_ids: continue
886                 create_ids.append(subscription_obj.create(cr, uid, {'res_model': self._name, 'res_id': id, 'user_id': user_id}, context=context))
887         return create_ids
888
889     def message_unsubscribe(self, cr, uid, ids, user_ids = None, context=None):
890         """ Unsubscribe the user (or user_ids) from the current document.
891             
892             :param user_ids: a list of user_ids; if not set, subscribe
893                              uid instead
894         """
895         # Trying to unsubscribe somebody not in subscribers: returns False
896         # if special management is needed; allows to know that an automatically
897         # subscribed user tries to unsubscribe and allows to warn him
898         to_unsubscribe_uids = [uid] if user_ids is None else user_ids
899         subscription_obj = self.pool.get('mail.subscription')
900         to_delete_sub_ids = subscription_obj.search(cr, uid,
901                         ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('user_id', 'in', to_unsubscribe_uids)], context=context)
902         if not to_delete_sub_ids:
903             return False
904         return subscription_obj.unlink(cr, uid, to_delete_sub_ids, context=context)
905
906     #------------------------------------------------------
907     # Notification API
908     #------------------------------------------------------
909
910     def message_create_notify_by_email(self, cr, uid, new_msg_values, user_to_notify_ids, context=None):
911         """ When creating a new message and pushing notifications, emails
912             must be send if users have chosen to receive notifications
913             by email via the notification_email_pref field.
914             
915             ``notification_email_pref`` can have 3 values :
916             - all: receive all notification by email (for example for shared
917               users)
918             - to_me: messages send directly to me (@login, messages on res.users)
919             - never: never receive notifications
920             Note that an user should never receive notifications for messages
921             he has created.
922             
923             :param new_msg_values: dictionary of message values, those that
924                                    are given to the create method
925             :param user_to_notify_ids: list of user_ids, user that will
926                                        receive a notification on their Wall
927         """
928         message_obj = self.pool.get('mail.message')
929         res_users_obj = self.pool.get('res.users')
930         body = new_msg_values.get('body_html', '') if new_msg_values.get('content_subtype') == 'html' else new_msg_values.get('body_text', '')
931         
932         # remove message writer
933         if user_to_notify_ids.count(new_msg_values.get('user_id')) > 0:
934             user_to_notify_ids.remove(new_msg_values.get('user_id'))
935         
936         # get user_ids directly asked
937         user_to_push_from_parse_ids = self.message_parse_users(cr, uid, body, context=context)
938         
939         # try to find an email_to
940         email_to = ''
941         for user in res_users_obj.browse(cr, uid, user_to_notify_ids, context=context):
942             if not user.notification_email_pref == 'all' and \
943                 not (user.notification_email_pref == 'to_me' and user.id in user_to_push_from_parse_ids):
944                 continue
945             if not user.user_email:
946                 continue
947             email_to = '%s, %s' % (email_to, user.user_email)
948             email_to = email_to.lstrip(', ')
949         
950         # did not find any email address: not necessary to create an email
951         if not email_to:
952             return
953         
954         # try to find an email_from
955         current_user = res_users_obj.browse(cr, uid, [uid], context=context)[0]
956         email_from = new_msg_values.get('email_from')
957         if not email_from:
958             email_from = current_user.user_email
959         
960         # get email content, create it (with mail_message.create)
961         email_values = self.message_create_notify_get_email_dict(cr, uid, new_msg_values, email_from, email_to, context)
962         email_id = message_obj.create(cr, uid, email_values, context=context)
963         return email_id
964     
965     def message_create_notify_get_email_dict(self, cr, uid, new_msg_values, email_from, email_to, context=None):
966         values = dict(new_msg_values)
967         
968         body_html = new_msg_values.get('body_html', '')
969         if body_html:
970             body_html += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
971         body_text = new_msg_values.get('body_text', '')
972         if body_text:
973             body_text += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
974         values.update({
975             'type': 'email',
976             'state': 'outgoing',
977             'email_from': email_from,
978             'email_to': email_to,
979             'subject': 'New message',
980             'content_subtype': new_msg_values.get('content_subtype', 'plain'),
981             'body_html': body_html,
982             'body_text': body_text,
983             'auto_delete': True,
984             'res_model': '',
985             'res_id': False,
986         })
987         return values
988
989     def message_remove_pushed_notifications(self, cr, uid, ids, msg_ids, remove_childs=True, context=None):
990         notif_obj = self.pool.get('mail.notification')
991         msg_obj = self.pool.get('mail.message')
992         if remove_childs:
993             notif_msg_ids = msg_obj.search(cr, uid, [('id', 'child_of', msg_ids)], context=context)
994         else:
995             notif_msg_ids = msg_ids
996         to_del_notif_ids = notif_obj.search(cr, uid, ['&', ('user_id', '=', uid), ('message_id', 'in', notif_msg_ids)], context=context)
997         return notif_obj.unlink(cr, uid, to_del_notif_ids, context=context)
998
999     #------------------------------------------------------
1000     # Thread_state
1001     #------------------------------------------------------
1002
1003     def message_create_set_unread(self, cr, uid, ids, context=None):
1004         """ When creating a new message, set as unread if uid is not the
1005             object responsible. """
1006         for obj in self.browse(cr, uid, ids, context=context):
1007             if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id != uid):
1008                 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1009
1010     def message_check_and_set_unread(self, cr, uid, ids, context=None):
1011         """ Set unread if uid is the object responsible or if the object has
1012             no responsible. """
1013         for obj in self.browse(cr, uid, ids, context=context):
1014             if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id == uid):
1015                 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1016
1017     def message_mark_as_unread(self, cr, uid, ids, context=None):
1018         """ Set as unread. """
1019         return self.write(cr, uid, ids, {'message_state': False}, context=context)
1020
1021     def message_check_and_set_read(self, cr, uid, ids, context=None):
1022         """ Set read if uid is the object responsible. """
1023         for obj in self.browse(cr, uid, ids, context=context):
1024             if not obj.message_state and hasattr(obj, 'user_id') and obj.user_id and obj.user_id.id == uid:
1025                 self.message_mark_as_read(cr, uid, [obj.id], context=context)
1026     
1027     def message_mark_as_read(self, cr, uid, ids, context=None):
1028         """ Set as read. """
1029         return self.write(cr, uid, ids, {'message_state': True}, context=context)
1030
1031
1032 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: