[IMP] Improved code and added
[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 datetime
24 import dateutil
25 import email
26 import logging
27 import pytz
28 import time
29 import xmlrpclib
30 from email.message import Message
31
32 from openerp import tools
33 from openerp import SUPERUSER_ID
34 from openerp.addons.mail.mail_message import decode
35 from openerp.osv import fields, osv, orm
36 from openerp.tools.safe_eval import safe_eval as eval
37 from openerp.tools.translate import _
38
39 _logger = logging.getLogger(__name__)
40
41
42 def decode_header(message, header, separator=' '):
43     return separator.join(map(decode, filter(None, message.get_all(header, []))))
44
45
46 class mail_thread(osv.AbstractModel):
47     ''' mail_thread model is meant to be inherited by any model that needs to
48         act as a discussion topic on which messages can be attached. Public
49         methods are prefixed with ``message_`` in order to avoid name
50         collisions with methods of the models that will inherit from this class.
51
52         ``mail.thread`` defines fields used to handle and display the
53         communication history. ``mail.thread`` also manages followers of
54         inheriting classes. All features and expected behavior are managed
55         by mail.thread. Widgets has been designed for the 7.0 and following
56         versions of OpenERP.
57
58         Inheriting classes are not required to implement any method, as the
59         default implementation will work for any model. However it is common
60         to override at least the ``message_new`` and ``message_update``
61         methods (calling ``super``) to add model-specific behavior at
62         creation and update of a thread when processing incoming emails.
63
64         Options:
65             - _mail_flat_thread: if set to True, all messages without parent_id
66                 are automatically attached to the first message posted on the
67                 ressource. If set to False, the display of Chatter is done using
68                 threads, and no parent_id is automatically set.
69     '''
70     _name = 'mail.thread'
71     _description = 'Email Thread'
72     _mail_flat_thread = True
73
74     # Automatic logging system if mail installed
75     # _track = {
76     #   'field': {
77     #       'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
78     #       'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
79     #   },
80     #   'field2': {
81     #       ...
82     #   },
83     # }
84     # where
85     #   :param string field: field name
86     #   :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
87     #   :param obj: is a browse_record
88     #   :param function lambda: returns whether the tracking should record using this subtype
89     _track = {}
90
91     def get_empty_list_help(self, cr, uid, help, context=None):
92         """ Override of BaseModel.get_empty_list_help() to generate an help message
93             that adds alias information. """
94         model = context.get('empty_list_help_model')
95         res_id = context.get('empty_list_help_id')
96         ir_config_parameter = self.pool.get("ir.config_parameter")
97         catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
98         document_name = context.get('empty_list_help_document_name', _('document'))
99         alias = None
100
101         if catchall_domain and model and res_id:  # specific res_id -> find its alias (i.e. section_id specified)
102             object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
103             # check that the alias effectively creates new records
104             if object_id.alias_id and object_id.alias_id.alias_name and \
105                     object_id.alias_id.alias_model_id and \
106                     object_id.alias_id.alias_model_id.model == self._name and \
107                     object_id.alias_id.alias_force_thread_id == 0:
108                 alias = object_id.alias_id
109         elif catchall_domain and model:  # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
110             model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
111             alias_obj = self.pool.get('mail.alias')
112             alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id), ("alias_name", "!=", False), ('alias_force_thread_id', '=', 0)], context=context, order='id ASC')
113             if alias_ids and len(alias_ids) == 1:  # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
114                 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
115
116         if alias:
117             alias_email = alias.name_get()[0][1]
118             return _("""<p class='oe_view_nocontent_create'>
119                             Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
120                         </p>
121                         %(static_help)s"""
122                     ) % {
123                         'document': document_name,
124                         'email': alias_email,
125                         'static_help': help or ''
126                     }
127
128         if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
129             return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
130                         'document': document_name,
131                         'static_help': help or '',
132                     }
133
134         return help
135
136     def _get_message_data(self, cr, uid, ids, name, args, context=None):
137         """ Computes:
138             - message_unread: has uid unread message for the document
139             - message_summary: html snippet summarizing the Chatter for kanban views """
140         res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
141         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
142
143         # search for unread messages, directly in SQL to improve performances
144         cr.execute("""  SELECT m.res_id FROM mail_message m
145                         RIGHT JOIN mail_notification n
146                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
147                         WHERE m.model = %s AND m.res_id in %s""",
148                     (user_pid, self._name, tuple(ids),))
149         for result in cr.fetchall():
150             res[result[0]]['message_unread'] = True
151             res[result[0]]['message_unread_count'] += 1
152
153         for id in ids:
154             if res[id]['message_unread_count']:
155                 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
156                 res[id]['message_summary'] = "<span class='oe_kanban_mail_new' title='%s'><span class='oe_e'>9</span> %d %s</span>" % (title, res[id].pop('message_unread_count'), _("New"))
157         return res
158
159     def read_followers_data(self, cr, uid, ids, context=None):
160         data = self.pool.get('res.partner').read(cr, uid, ids, ['name', 'user_ids'], context=context)
161
162         grp_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'base', 'group_no_one')
163         tech_user = self.pool.get('res.groups').read(cr, uid, grp_id[1], ['users'], context=context)['users']
164         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
165
166         if uid in tech_user:
167             for dict in data:
168                 if dict['id'] in [user_pid]:
169                     dict.update({'is_editable': True})
170                 if uid in dict['user_ids']:
171                     dict.update({'is_uid': True})
172
173         return data
174
175     def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
176         """ Computes:
177             - message_subtype_data: data about document subtypes: which are
178                 available, which are followed if any """
179         res = dict((id, dict(message_subtype_data='')) for id in ids)
180         if user_pid is None:
181             user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
182
183         # find current model subtypes, add them to a dictionary
184         subtype_obj = self.pool.get('mail.message.subtype')
185         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
186         subtype_dict = dict((subtype.name, dict(default=subtype.default, followed=False, id=subtype.id)) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
187         for id in ids:
188             res[id]['message_subtype_data'] = subtype_dict.copy()
189
190         # find the document followers, update the data
191         fol_obj = self.pool.get('mail.followers')
192         fol_ids = fol_obj.search(cr, uid, [
193             ('partner_id', '=', user_pid),
194             ('res_id', 'in', ids),
195             ('res_model', '=', self._name),
196         ], context=context)
197         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
198             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
199             for subtype in fol.subtype_ids:
200                 thread_subtype_dict[subtype.name]['followed'] = True
201             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
202
203         return res
204
205     def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
206         return [('message_ids.to_read', '=', True)]
207
208     def _get_followers(self, cr, uid, ids, name, arg, context=None):
209         fol_obj = self.pool.get('mail.followers')
210         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
211         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
212         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
213         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
214             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
215             if fol.partner_id.id == user_pid:
216                 res[fol.res_id]['message_is_follower'] = True
217         return res
218
219     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
220         if not value:
221             return
222         partner_obj = self.pool.get('res.partner')
223         fol_obj = self.pool.get('mail.followers')
224
225         # read the old set of followers, and determine the new set of followers
226         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
227         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
228         new = set(old)
229
230         for command in value or []:
231             if isinstance(command, (int, long)):
232                 new.add(command)
233             elif command[0] == 0:
234                 new.add(partner_obj.create(cr, uid, command[2], context=context))
235             elif command[0] == 1:
236                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
237                 new.add(command[1])
238             elif command[0] == 2:
239                 partner_obj.unlink(cr, uid, [command[1]], context=context)
240                 new.discard(command[1])
241             elif command[0] == 3:
242                 new.discard(command[1])
243             elif command[0] == 4:
244                 new.add(command[1])
245             elif command[0] == 5:
246                 new.clear()
247             elif command[0] == 6:
248                 new = set(command[2])
249
250         # remove partners that are no longer followers
251         fol_ids = fol_obj.search(cr, SUPERUSER_ID,
252             [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
253         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
254
255         # add new followers
256         for partner_id in new - old:
257             fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
258
259     def _search_followers(self, cr, uid, obj, name, args, context):
260         """Search function for message_follower_ids
261
262         Do not use with operator 'not in'. Use instead message_is_followers
263         """
264         fol_obj = self.pool.get('mail.followers')
265         res = []
266         for field, operator, value in args:
267             assert field == name
268             # TOFIX make it work with not in
269             assert operator != "not in", "Do not search message_follower_ids with 'not in'"
270             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
271             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
272             res.append(('id', 'in', res_ids))
273         return res
274
275     def _search_is_follower(self, cr, uid, obj, name, args, context):
276         """Search function for message_is_follower"""
277         res = []
278         for field, operator, value in args:
279             assert field == name
280             partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
281             if (operator == '=' and value) or (operator == '!=' and not value):  # is a follower
282                 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
283             else:  # is not a follower or unknown domain
284                 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
285                 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
286             res.append(('id', 'in', res_ids))
287         return res
288
289     _columns = {
290         'message_is_follower': fields.function(_get_followers, type='boolean',
291             fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
292         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
293             fnct_search=_search_followers, type='many2many',
294             obj='res.partner', string='Followers', multi='_get_followers'),
295         'message_ids': fields.one2many('mail.message', 'res_id',
296             domain=lambda self: [('model', '=', self._name)],
297             auto_join=True,
298             string='Messages',
299             help="Messages and communication history"),
300         'message_unread': fields.function(_get_message_data,
301             fnct_search=_search_message_unread, multi="_get_message_data",
302             type='boolean', string='Unread Messages',
303             help="If checked new messages require your attention."),
304         'message_summary': fields.function(_get_message_data, method=True,
305             type='text', string='Summary', multi="_get_message_data",
306             help="Holds the Chatter summary (number of messages, ...). "\
307                  "This summary is directly in html format in order to "\
308                  "be inserted in kanban views."),
309     }
310
311     #------------------------------------------------------
312     # CRUD overrides for automatic subscription and logging
313     #------------------------------------------------------
314
315     def create(self, cr, uid, values, context=None):
316         """ Chatter override :
317             - subscribe uid
318             - subscribe followers of parent
319             - log a creation message
320         """
321         if context is None:
322             context = {}
323         thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
324
325         # automatic logging unless asked not to (mainly for various testing purpose)
326         if not context.get('mail_create_nolog'):
327             self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
328
329         # subscribe uid unless asked not to
330         if not context.get('mail_create_nosubscribe'):
331             self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
332         # auto_subscribe: take values and defaults into account
333         create_values = set(values.keys())
334         for key, val in context.iteritems():
335             if key.startswith('default_'):
336                 create_values.add(key[8:])
337         self.message_auto_subscribe(cr, uid, [thread_id], list(create_values), context=context)
338
339         # track values
340         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
341         if tracked_fields:
342             initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
343             self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
344
345         return thread_id
346
347     def write(self, cr, uid, ids, values, context=None):
348         if isinstance(ids, (int, long)):
349             ids = [ids]
350         # Track initial values of tracked fields
351         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
352         if tracked_fields:
353             records = self.browse(cr, uid, ids, context=context)
354             initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
355
356         # Perform write, update followers
357         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
358         self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
359
360         # Perform the tracking
361         if tracked_fields:
362             self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
363         return result
364
365     def unlink(self, cr, uid, ids, context=None):
366         """ Override unlink to delete messages and followers. This cannot be
367             cascaded, because link is done through (res_model, res_id). """
368         msg_obj = self.pool.get('mail.message')
369         fol_obj = self.pool.get('mail.followers')
370         # delete messages and notifications
371         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
372         msg_obj.unlink(cr, uid, msg_ids, context=context)
373         # delete
374         res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
375         # delete followers
376         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
377         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
378         return res
379
380     def copy(self, cr, uid, id, default=None, context=None):
381         default = default or {}
382         default['message_ids'] = []
383         default['message_follower_ids'] = []
384         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
385
386     #------------------------------------------------------
387     # Automatically log tracked fields
388     #------------------------------------------------------
389
390     def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
391         """ Return a structure of tracked fields for the current model.
392             :param list updated_fields: modified field names
393             :return list: a list of (field_name, column_info obj), containing
394                 always tracked fields and modified on_change fields
395         """
396         lst = []
397         for name, column_info in self._all_columns.items():
398             visibility = getattr(column_info.column, 'track_visibility', False)
399             if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
400                 lst.append(name)
401         if not lst:
402             return lst
403         return self.fields_get(cr, uid, lst, context=context)
404
405     def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
406
407         def convert_for_display(value, col_info):
408             if not value and col_info['type'] == 'boolean':
409                 return 'False'
410             if not value:
411                 return ''
412             if col_info['type'] == 'many2one':
413                 return value.name_get()[0][1]
414             if col_info['type'] == 'selection':
415                 return dict(col_info['selection'])[value]
416             return value
417
418         def format_message(message_description, tracked_values):
419             message = ''
420             if message_description:
421                 message = '<span>%s</span>' % message_description
422             for name, change in tracked_values.items():
423                 message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % change.get('col_info')
424                 if change.get('old_value'):
425                     message += '%s &rarr; ' % change.get('old_value')
426                 message += '%s</div>' % change.get('new_value')
427             return message
428
429         if not tracked_fields:
430             return True
431
432         for browse_record in self.browse(cr, uid, ids, context=context):
433             initial = initial_values[browse_record.id]
434             changes = set()
435             tracked_values = {}
436
437             # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
438             for col_name, col_info in tracked_fields.items():
439                 initial_value = initial[col_name]
440                 record_value = getattr(browse_record, col_name)
441
442                 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
443                     tracked_values[col_name] = dict(col_info=col_info['string'],
444                                                         new_value=convert_for_display(record_value, col_info))
445                 elif record_value != initial_value and (record_value or initial_value):  # because browse null != False
446                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
447                         tracked_values[col_name] = dict(col_info=col_info['string'],
448                                                             old_value=convert_for_display(initial_value, col_info),
449                                                             new_value=convert_for_display(record_value, col_info))
450                     if col_name in tracked_fields:
451                         changes.add(col_name)
452             if not changes:
453                 continue
454
455             # find subtypes and post messages or log if no subtype found
456             subtypes = []
457             for field, track_info in self._track.items():
458                 if field not in changes:
459                     continue
460                 for subtype, method in track_info.items():
461                     if method(self, cr, uid, browse_record, context):
462                         subtypes.append(subtype)
463
464             posted = False
465             for subtype in subtypes:
466                 try:
467                     subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
468                 except ValueError, e:
469                     _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
470                     continue
471                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
472                 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
473                 posted = True
474             if not posted:
475                 message = format_message('', tracked_values)
476                 self.message_post(cr, uid, browse_record.id, body=message, context=context)
477         return True
478
479     #------------------------------------------------------
480     # mail.message wrappers and tools
481     #------------------------------------------------------
482
483     def _needaction_domain_get(self, cr, uid, context=None):
484         if self._needaction:
485             return [('message_unread', '=', True)]
486         return []
487
488     def _garbage_collect_attachments(self, cr, uid, context=None):
489         """ Garbage collect lost mail attachments. Those are attachments
490             - linked to res_model 'mail.compose.message', the composer wizard
491             - with res_id 0, because they were created outside of an existing
492                 wizard (typically user input through Chatter or reports
493                 created on-the-fly by the templates)
494             - unused since at least one day (create_date and write_date)
495         """
496         limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
497         limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
498         ir_attachment_obj = self.pool.get('ir.attachment')
499         attach_ids = ir_attachment_obj.search(cr, uid, [
500                             ('res_model', '=', 'mail.compose.message'),
501                             ('res_id', '=', 0),
502                             ('create_date', '<', limit_date_str),
503                             ('write_date', '<', limit_date_str),
504                             ], context=context)
505         ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
506         return True
507
508     def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
509         """ mail.message check permission rules for related document. This method is
510             meant to be inherited in order to implement addons-specific behavior.
511             A common behavior would be to allow creating messages when having read
512             access rule on the document, for portal document such as issues. """
513         if not model_obj:
514             model_obj = self
515         if operation in ['create', 'write', 'unlink']:
516             model_obj.check_access_rights(cr, uid, 'write')
517             model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
518         else:
519             model_obj.check_access_rights(cr, uid, operation)
520             model_obj.check_access_rule(cr, uid, mids, operation, context=context)
521
522     def _get_formview_action(self, cr, uid, id, model=None, context=None):
523         """ Return an action to open the document. This method is meant to be
524             overridden in addons that want to give specific view ids for example.
525
526             :param int id: id of the document to open
527             :param string model: specific model that overrides self._name
528         """
529         return {
530                 'type': 'ir.actions.act_window',
531                 'res_model': model or self._name,
532                 'view_type': 'form',
533                 'view_mode': 'form',
534                 'views': [(False, 'form')],
535                 'target': 'current',
536                 'res_id': id,
537             }
538
539     def _get_inbox_action_xml_id(self, cr, uid, context=None):
540         """ When redirecting towards the Inbox, choose which action xml_id has
541             to be fetched. This method is meant to be inherited, at least in portal
542             because portal users have a different Inbox action than classic users. """
543         return ('mail', 'action_mail_inbox_feeds')
544
545     def message_redirect_action(self, cr, uid, context=None):
546         """ For a given message, return an action that either
547             - opens the form view of the related document if model, res_id, and
548               read access to the document
549             - opens the Inbox with a default search on the conversation if model,
550               res_id
551             - opens the Inbox with context propagated
552
553         """
554         if context is None:
555             context = {}
556
557         # default action is the Inbox action
558         self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
559         act_model, act_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, *self._get_inbox_action_xml_id(cr, uid, context=context))
560         action = self.pool.get(act_model).read(cr, uid, act_id, [])
561
562         # if msg_id specified: try to redirect to the document or fallback on the Inbox
563         msg_id = context.get('params', {}).get('message_id')
564         if not msg_id:
565             return action
566         msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
567         if msg.model and msg.res_id:
568             action.update({
569                 'context': {
570                     'search_default_model': msg.model,
571                     'search_default_res_id': msg.res_id,
572                 }
573             })
574             if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
575                 try:
576                     model_obj = self.pool.get(msg.model)
577                     model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
578                     if not hasattr(model_obj, '_get_formview_action'):
579                         action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
580                     else:
581                         action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
582                 except (osv.except_osv, orm.except_orm):
583                     pass
584         return action
585
586     #------------------------------------------------------
587     # Email specific
588     #------------------------------------------------------
589
590     def message_get_reply_to(self, cr, uid, ids, context=None):
591         """ Returns the preferred reply-to email address that is basically
592             the alias of the document, if it exists. """
593         if not self._inherits.get('mail.alias'):
594             return [False for id in ids]
595         return ["%s@%s" % (record['alias_name'], record['alias_domain'])
596                     if record.get('alias_domain') and record.get('alias_name')
597                     else False
598                     for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
599
600     #------------------------------------------------------
601     # Mail gateway
602     #------------------------------------------------------
603
604     def message_capable_models(self, cr, uid, context=None):
605         """ Used by the plugin addon, based for plugin_outlook and others. """
606         ret_dict = {}
607         for model_name in self.pool.obj_list():
608             model = self.pool[model_name]
609             if hasattr(model, "message_process") and hasattr(model, "message_post"):
610                 ret_dict[model_name] = model._description
611         return ret_dict
612
613     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
614         """ Find partners related to some header fields of the message.
615
616             :param string message: an email.message instance """
617         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
618         return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
619
620     def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
621         """ Verify route validity. Check and rules:
622             1 - if thread_id -> check that document effectively exists; otherwise
623                 fallback on a message_new by resetting thread_id
624             2 - check that message_update exists if thread_id is set; or at least
625                 that message_new exist
626             [ - find author_id if udpate_author is set]
627             3 - if there is an alias, check alias_contact:
628                 'followers' and thread_id:
629                     check on target document that the author is in the followers
630                 'followers' and alias_parent_thread_id:
631                     check on alias parent document that the author is in the
632                     followers
633                 'partners': check that author_id id set
634         """
635
636         assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
637         assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
638
639         message_id = message.get('Message-Id')
640         email_from = decode_header(message, 'From')
641         author_id = message_dict.get('author_id')
642         model, thread_id, alias = route[0], route[1], route[4]
643         model_pool = None
644
645         def _create_bounce_email():
646             mail_mail = self.pool.get('mail.mail')
647             mail_id = mail_mail.create(cr, uid, {
648                             'body_html': '<div><p>Hello,</p>'
649                                 '<p>The following email sent to %s cannot be accepted because this is '
650                                 'a private email address. Only allowed people can contact us at this address.</p></div>'
651                                 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
652                             'subject': 'Re: %s' % message.get('subject'),
653                             'email_to': message.get('from'),
654                             'auto_delete': True,
655                         }, context=context)
656             mail_mail.send(cr, uid, [mail_id], context=context)
657
658         def _warn(message):
659             _logger.warning('Routing mail with Message-Id %s: route %s: %s',
660                                 message_id, route, message)
661
662         # Wrong model
663         if model and not model in self.pool:
664             if assert_model:
665                 assert model in self.pool, 'Routing: unknown target model %s' % model
666             _warn('unknown target model %s' % model)
667             return ()
668         elif model:
669             model_pool = self.pool[model]
670
671         # Private message: should not contain any thread_id
672         if not model and thread_id:
673             if assert_model:
674                 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
675             _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
676             thread_id = 0
677
678         # Existing Document: check if exists; if not, fallback on create if allowed
679         if thread_id and not model_pool.exists(cr, uid, thread_id):
680             if create_fallback:
681                 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
682                 thread_id = None
683             elif assert_model:
684                 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
685             else:
686                 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
687                 return ()
688
689         # Existing Document: check model accepts the mailgateway
690         if thread_id and not hasattr(model_pool, 'message_update'):
691             if create_fallback:
692                 _warn('model %s does not accept document update, fall back on document creation' % model)
693                 thread_id = None
694             elif assert_model:
695                 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
696             else:
697                 _warn('model %s does not accept document update, skipping' % model)
698                 return ()
699
700         # New Document: check model accepts the mailgateway
701         if not thread_id and not hasattr(model_pool, 'message_new'):
702             if assert_model:
703                 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
704             _warn('model %s does not accept document creation, skipping' % model)
705             return ()
706
707         # Update message author if asked
708         # We do it now because we need it for aliases (contact settings)
709         if not author_id and update_author:
710             author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
711             if author_ids:
712                 author_id = author_ids[0]
713                 message_dict['author_id'] = author_id
714
715         # Alias: check alias_contact settings
716         if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
717             if thread_id:
718                 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
719             else:
720                 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
721             if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
722                 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
723                 _create_bounce_email()
724                 return ()
725         elif alias and alias.alias_contact == 'partners' and not author_id:
726             _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
727             _create_bounce_email()
728             return ()
729
730         return (model, thread_id, route[2], route[3], route[4])
731
732     def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
733                       custom_values=None, context=None):
734         """Attempt to figure out the correct target model, thread_id,
735         custom_values and user_id to use for an incoming message.
736         Multiple values may be returned, if a message had multiple
737         recipients matching existing mail.aliases, for example.
738
739         The following heuristics are used, in this order:
740              1. If the message replies to an existing thread_id, and
741                 properly contains the thread model in the 'In-Reply-To'
742                 header, use this model/thread_id pair, and ignore
743                 custom_value (not needed as no creation will take place)
744              2. Look for a mail.alias entry matching the message
745                 recipient, and use the corresponding model, thread_id,
746                 custom_values and user_id.
747              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
748                 provided.
749              4. If all the above fails, raise an exception.
750
751            :param string message: an email.message instance
752            :param dict message_dict: dictionary holding message variables
753            :param string model: the fallback model to use if the message
754                does not match any of the currently configured mail aliases
755                (may be None if a matching alias is supposed to be present)
756            :type dict custom_values: optional dictionary of default field values
757                 to pass to ``message_new`` if a new record needs to be created.
758                 Ignored if the thread record already exists, and also if a
759                 matching mail.alias was found (aliases define their own defaults)
760            :param int thread_id: optional ID of the record/thread from ``model``
761                to which this mail should be attached. Only used if the message
762                does not reply to an existing thread and does not match any mail alias.
763            :return: list of [model, thread_id, custom_values, user_id, alias]
764         """
765         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
766         fallback_model = model
767
768         # Get email.message.Message variables for future processing
769         message_id = message.get('Message-Id')
770         email_from = decode_header(message, 'From')
771         email_to = decode_header(message, 'To')
772         references = decode_header(message, 'References')
773         in_reply_to = decode_header(message, 'In-Reply-To')
774
775         # 1. Verify if this is a reply to an existing thread
776         thread_references = references or in_reply_to
777         ref_match = thread_references and tools.reference_re.search(thread_references)
778         if ref_match:
779             thread_id = int(ref_match.group(1))
780             model = ref_match.group(2) or fallback_model
781             if thread_id and model in self.pool:
782                 model_obj = self.pool[model]
783                 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
784                     _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
785                                     email_from, email_to, message_id, model, thread_id, custom_values, uid)
786                     route = self.message_route_verify(cr, uid, message, message_dict,
787                                     (model, thread_id, custom_values, uid, None),
788                                     update_author=True, assert_model=True, create_fallback=True, context=context)
789                     return route and [route] or []
790
791         # 2. Reply to a private message
792         if in_reply_to:
793             message_ids = self.pool.get('mail.message').search(cr, uid, [
794                                 ('message_id', '=', in_reply_to),
795                                 '!', ('message_id', 'ilike', 'reply_to')
796                             ], limit=1, context=context)
797             if message_ids:
798                 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
799                 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
800                                 email_from, email_to, message_id, message.id, custom_values, uid)
801                 route = self.message_route_verify(cr, uid, message, message_dict,
802                                 (message.model, message.res_id, custom_values, uid, None),
803                                 update_author=True, assert_model=True, create_fallback=True, context=context)
804                 return route and [route] or []
805
806         # 3. Look for a matching mail.alias entry
807         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
808         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
809         rcpt_tos = \
810              ','.join([decode_header(message, 'Delivered-To'),
811                        decode_header(message, 'To'),
812                        decode_header(message, 'Cc'),
813                        decode_header(message, 'Resent-To'),
814                        decode_header(message, 'Resent-Cc')])
815         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
816         if local_parts:
817             mail_alias = self.pool.get('mail.alias')
818             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
819             if alias_ids:
820                 routes = []
821                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
822                     user_id = alias.alias_user_id.id
823                     if not user_id:
824                         # TDE note: this could cause crashes, because no clue that the user
825                         # that send the email has the right to create or modify a new document
826                         # Fallback on user_id = uid
827                         # Note: recognized partners will be added as followers anyway
828                         # user_id = self._message_find_user_id(cr, uid, message, context=context)
829                         user_id = uid
830                         _logger.info('No matching user_id for the alias %s', alias.alias_name)
831                     route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
832                     _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
833                                 email_from, email_to, message_id, route)
834                     route = self.message_route_verify(cr, uid, message, message_dict, route,
835                                 update_author=True, assert_model=True, create_fallback=True, context=context)
836                     if route:
837                         routes.append(route)
838                 return routes
839
840         # 4. Fallback to the provided parameters, if they work
841         if not thread_id:
842             # Legacy: fallback to matching [ID] in the Subject
843             match = tools.res_re.search(decode_header(message, 'Subject'))
844             thread_id = match and match.group(1)
845             # Convert into int (bug spotted in 7.0 because of str)
846             try:
847                 thread_id = int(thread_id)
848             except:
849                 thread_id = False
850         _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
851                     email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
852         route = self.message_route_verify(cr, uid, message, message_dict,
853                         (fallback_model, thread_id, custom_values, uid, None),
854                         update_author=True, assert_model=True, context=context)
855         if route:
856             return [route]
857
858         # AssertionError if no routes found and if no bounce occured
859         assert False, \
860             "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
861             "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
862
863     def message_process(self, cr, uid, model, message, custom_values=None,
864                         save_original=False, strip_attachments=False,
865                         thread_id=None, context=None):
866         """ Process an incoming RFC2822 email message, relying on
867             ``mail.message.parse()`` for the parsing operation,
868             and ``message_route()`` to figure out the target model.
869
870             Once the target model is known, its ``message_new`` method
871             is called with the new message (if the thread record did not exist)
872             or its ``message_update`` method (if it did).
873
874             There is a special case where the target model is False: a reply
875             to a private message. In this case, we skip the message_new /
876             message_update step, to just post a new message using mail_thread
877             message_post.
878
879            :param string model: the fallback model to use if the message
880                does not match any of the currently configured mail aliases
881                (may be None if a matching alias is supposed to be present)
882            :param message: source of the RFC2822 message
883            :type message: string or xmlrpclib.Binary
884            :type dict custom_values: optional dictionary of field values
885                 to pass to ``message_new`` if a new record needs to be created.
886                 Ignored if the thread record already exists, and also if a
887                 matching mail.alias was found (aliases define their own defaults)
888            :param bool save_original: whether to keep a copy of the original
889                 email source attached to the message after it is imported.
890            :param bool strip_attachments: whether to strip all attachments
891                 before processing the message, in order to save some space.
892            :param int thread_id: optional ID of the record/thread from ``model``
893                to which this mail should be attached. When provided, this
894                overrides the automatic detection based on the message
895                headers.
896         """
897         if context is None:
898             context = {}
899
900         # extract message bytes - we are forced to pass the message as binary because
901         # we don't know its encoding until we parse its headers and hence can't
902         # convert it to utf-8 for transport between the mailgate script and here.
903         if isinstance(message, xmlrpclib.Binary):
904             message = str(message.data)
905         # Warning: message_from_string doesn't always work correctly on unicode,
906         # we must use utf-8 strings here :-(
907         if isinstance(message, unicode):
908             message = message.encode('utf-8')
909         msg_txt = email.message_from_string(message)
910
911         # parse the message, verify we are not in a loop by checking message_id is not duplicated
912         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
913         if strip_attachments:
914             msg.pop('attachments', None)
915         # postpone setting msg.partner_ids after message_post, to avoid double notifications
916         partner_ids = msg.pop('partner_ids', [])
917         if msg.get('message_id'):   # should always be True as message_parse generate one if missing
918             existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
919                                                                 ('message_id', '=', msg.get('message_id')),
920                                                                 ], context=context)
921             if existing_msg_ids:
922                 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
923                                 msg.get('from'), msg.get('to'), msg.get('message_id'))
924                 return False
925
926         # find possible routes for the message
927         routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
928         thread_id = False
929         for model, thread_id, custom_values, user_id, alias in routes:
930             if self._name == 'mail.thread':
931                 context.update({'thread_model': model})
932             if model:
933                 model_pool = self.pool[model]
934                 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
935                     "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
936                         (msg['message_id'], model)
937
938                 # disabled subscriptions during message_new/update to avoid having the system user running the
939                 # email gateway become a follower of all inbound messages
940                 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
941                 if thread_id and hasattr(model_pool, 'message_update'):
942                     model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
943                 else:
944                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
945             else:
946                 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
947                 model_pool = self.pool.get('mail.thread')
948             if not hasattr(model_pool, 'message_post'):
949                 context['thread_model'] = model
950                 model_pool = self.pool['mail.thread']
951             new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
952
953             if partner_ids:
954                 # postponed after message_post, because this is an external message and we don't want to create
955                 # duplicate emails due to notifications
956                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
957
958         return thread_id
959
960     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
961         """Called by ``message_process`` when a new message is received
962            for a given thread model, if the message did not belong to
963            an existing thread.
964            The default behavior is to create a new record of the corresponding
965            model (based on some very basic info extracted from the message).
966            Additional behavior may be implemented by overriding this method.
967
968            :param dict msg_dict: a map containing the email details and
969                                  attachments. See ``message_process`` and
970                                 ``mail.message.parse`` for details.
971            :param dict custom_values: optional dictionary of additional
972                                       field values to pass to create()
973                                       when creating the new thread record.
974                                       Be careful, these values may override
975                                       any other values coming from the message.
976            :param dict context: if a ``thread_model`` value is present
977                                 in the context, its value will be used
978                                 to determine the model of the record
979                                 to create (instead of the current model).
980            :rtype: int
981            :return: the id of the newly created thread object
982         """
983         if context is None:
984             context = {}
985         data = {}
986         if isinstance(custom_values, dict):
987             data = custom_values.copy()
988         model = context.get('thread_model') or self._name
989         model_pool = self.pool[model]
990         fields = model_pool.fields_get(cr, uid, context=context)
991         if 'name' in fields and not data.get('name'):
992             data['name'] = msg_dict.get('subject', '')
993         res_id = model_pool.create(cr, uid, data, context=context)
994         return res_id
995
996     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
997         """Called by ``message_process`` when a new message is received
998            for an existing thread. The default behavior is to update the record
999            with update_vals taken from the incoming email.
1000            Additional behavior may be implemented by overriding this
1001            method.
1002            :param dict msg_dict: a map containing the email details and
1003                                attachments. See ``message_process`` and
1004                                ``mail.message.parse()`` for details.
1005            :param dict update_vals: a dict containing values to update records
1006                               given their ids; if the dict is None or is
1007                               void, no write operation is performed.
1008         """
1009         if update_vals:
1010             self.write(cr, uid, ids, update_vals, context=context)
1011         return True
1012
1013     def _message_extract_payload(self, message, save_original=False):
1014         """Extract body as HTML and attachments from the mail message"""
1015         attachments = []
1016         body = u''
1017         if save_original:
1018             attachments.append(('original_email.eml', message.as_string()))
1019         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1020             encoding = message.get_content_charset()
1021             body = message.get_payload(decode=True)
1022             body = tools.ustr(body, encoding, errors='replace')
1023             if message.get_content_type() == 'text/plain':
1024                 # text/plain -> <pre/>
1025                 body = tools.append_content_to_html(u'', body, preserve=True)
1026         else:
1027             alternative = (message.get_content_type() == 'multipart/alternative')
1028             for part in message.walk():
1029                 if part.get_content_maintype() == 'multipart':
1030                     continue  # skip container
1031                 filename = part.get_filename()  # None if normal part
1032                 encoding = part.get_content_charset()  # None if attachment
1033                 # 1) Explicit Attachments -> attachments
1034                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1035                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1036                     continue
1037                 # 2) text/plain -> <pre/>
1038                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1039                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1040                                                                          encoding, errors='replace'), preserve=True)
1041                 # 3) text/html -> raw
1042                 elif part.get_content_type() == 'text/html':
1043                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1044                     if alternative:
1045                         body = html
1046                     else:
1047                         body = tools.append_content_to_html(body, html, plaintext=False)
1048                 # 4) Anything else -> attachment
1049                 else:
1050                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1051         return body, attachments
1052
1053     def message_parse(self, cr, uid, message, save_original=False, context=None):
1054         """Parses a string or email.message.Message representing an
1055            RFC-2822 email, and returns a generic dict holding the
1056            message details.
1057
1058            :param message: the message to parse
1059            :type message: email.message.Message | string | unicode
1060            :param bool save_original: whether the returned dict
1061                should include an ``original`` attachment containing
1062                the source of the message
1063            :rtype: dict
1064            :return: A dict with the following structure, where each
1065                     field may not be present if missing in original
1066                     message::
1067
1068                     { 'message_id': msg_id,
1069                       'subject': subject,
1070                       'from': from,
1071                       'to': to,
1072                       'cc': cc,
1073                       'body': unified_body,
1074                       'attachments': [('file1', 'bytes'),
1075                                       ('file2', 'bytes')}
1076                     }
1077         """
1078         msg_dict = {
1079             'type': 'email',
1080         }
1081         if not isinstance(message, Message):
1082             if isinstance(message, unicode):
1083                 # Warning: message_from_string doesn't always work correctly on unicode,
1084                 # we must use utf-8 strings here :-(
1085                 message = message.encode('utf-8')
1086             message = email.message_from_string(message)
1087
1088         message_id = message['message-id']
1089         if not message_id:
1090             # Very unusual situation, be we should be fault-tolerant here
1091             message_id = "<%s@localhost>" % time.time()
1092             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1093         msg_dict['message_id'] = message_id
1094
1095         if message.get('Subject'):
1096             msg_dict['subject'] = decode(message.get('Subject'))
1097
1098         # Envelope fields not stored in mail.message but made available for message_new()
1099         msg_dict['from'] = decode(message.get('from'))
1100         msg_dict['to'] = decode(message.get('to'))
1101         msg_dict['cc'] = decode(message.get('cc'))
1102         msg_dict['email_from'] = decode(message.get('from'))
1103         partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1104         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1105
1106         if message.get('Date'):
1107             try:
1108                 date_hdr = decode(message.get('Date'))
1109                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1110                 if parsed_date.utcoffset() is None:
1111                     # naive datetime, so we arbitrarily decide to make it
1112                     # UTC, there's no better choice. Should not happen,
1113                     # as RFC2822 requires timezone offset in Date headers.
1114                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
1115                 else:
1116                     stored_date = parsed_date.astimezone(tz=pytz.utc)
1117             except Exception:
1118                 _logger.warning('Failed to parse Date header %r in incoming mail '
1119                                 'with message-id %r, assuming current date/time.',
1120                                 message.get('Date'), message_id)
1121                 stored_date = datetime.datetime.now()
1122             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1123
1124         if message.get('In-Reply-To'):
1125             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1126             if parent_ids:
1127                 msg_dict['parent_id'] = parent_ids[0]
1128
1129         if message.get('References') and 'parent_id' not in msg_dict:
1130             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1131                                                                          [x.strip() for x in decode(message['References']).split()])])
1132             if parent_ids:
1133                 msg_dict['parent_id'] = parent_ids[0]
1134
1135         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1136         return msg_dict
1137
1138     #------------------------------------------------------
1139     # Note specific
1140     #------------------------------------------------------
1141
1142     def log(self, cr, uid, id, message, secondary=False, context=None):
1143         _logger.warning("log() is deprecated. As this module inherit from "\
1144                         "mail.thread, the message will be managed by this "\
1145                         "module instead of by the res.log mechanism. Please "\
1146                         "use mail_thread.message_post() instead of the "\
1147                         "now deprecated res.log.")
1148         self.message_post(cr, uid, [id], message, context=context)
1149
1150     def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1151         """ Called by message_get_suggested_recipients, to add a suggested
1152             recipient in the result dictionary. The form is :
1153                 partner_id, partner_name<partner_email> or partner_name, reason """
1154         if email and not partner:
1155             # get partner info from email
1156             partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1157             if partner_info.get('partner_id'):
1158                 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1159         if email and email in [val[1] for val in result[obj.id]]:  # already existing email -> skip
1160             return result
1161         if partner and partner in obj.message_follower_ids:  # recipient already in the followers -> skip
1162             return result
1163         if partner and partner in [val[0] for val in result[obj.id]]:  # already existing partner ID -> skip
1164             return result
1165         if partner and partner.email:  # complete profile: id, name <email>
1166             result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1167         elif partner:  # incomplete profile: id, name
1168             result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1169         else:  # unknown partner, we are probably managing an email address
1170             result[obj.id].append((False, email, reason))
1171         return result
1172
1173     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1174         """ Returns suggested recipients for ids. Those are a list of
1175             tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1176         result = dict.fromkeys(ids, list())
1177         if self._all_columns.get('user_id'):
1178             for obj in self.browse(cr, SUPERUSER_ID, ids, context=context):  # SUPERUSER because of a read on res.users that would crash otherwise
1179                 if not obj.user_id or not obj.user_id.partner_id:
1180                     continue
1181                 self._message_add_suggested_recipient(cr, uid, result, obj, partner=obj.user_id.partner_id, reason=self._all_columns['user_id'].column.string, context=context)
1182         return result
1183
1184     def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1185         """ Utility method to find partners from email addresses. The rules are :
1186             1 - check in document (model | self, id) followers
1187             2 - try to find a matching partner that is also an user
1188             3 - try to find a matching partner
1189
1190             :param list emails: list of email addresses
1191             :param string model: model to fetch related record; by default self
1192                 is used.
1193             :param boolean check_followers: check in document followers
1194         """
1195         partner_obj = self.pool['res.partner']
1196         partner_ids = []
1197         obj = None
1198         if id and (model or self._name != 'mail.thread') and check_followers:
1199             if model:
1200                 obj = self.pool[model].browse(cr, uid, id, context=context)
1201             else:
1202                 obj = self.browse(cr, uid, id, context=context)
1203         for contact in emails:
1204             partner_id = False
1205             email_address = tools.email_split(contact)
1206             if not email_address:
1207                 partner_ids.append(partner_id)
1208                 continue
1209             email_address = email_address[0]
1210             # first try: check in document's followers
1211             if obj:
1212                 for follower in obj.message_follower_ids:
1213                     if follower.email == email_address:
1214                         partner_id = follower.id
1215             # second try: check in partners that are also users
1216             if not partner_id:
1217                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1218                                                 ('email', 'ilike', email_address),
1219                                                 ('user_ids', '!=', False)
1220                                             ], limit=1, context=context)
1221                 if ids:
1222                     partner_id = ids[0]
1223             # third try: check in partners
1224             if not partner_id:
1225                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1226                                                 ('email', 'ilike', email_address)
1227                                             ], limit=1, context=context)
1228                 if ids:
1229                     partner_id = ids[0]
1230             partner_ids.append(partner_id)
1231         return partner_ids
1232
1233     def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1234         """ Convert a list of emails into a list partner_ids and a list
1235             new_partner_ids. The return value is non conventional because
1236             it is meant to be used by the mail widget.
1237
1238             :return dict: partner_ids and new_partner_ids """
1239         mail_message_obj = self.pool.get('mail.message')
1240         partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1241         result = list()
1242         for idx in range(len(emails)):
1243             email_address = emails[idx]
1244             partner_id = partner_ids[idx]
1245             partner_info = {'full_name': email_address, 'partner_id': partner_id}
1246             result.append(partner_info)
1247
1248             # link mail with this from mail to the new partner id
1249             if link_mail and partner_info['partner_id']:
1250                 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1251                                     '|',
1252                                     ('email_from', '=', email_address),
1253                                     ('email_from', 'ilike', '<%s>' % email_address),
1254                                     ('author_id', '=', False)
1255                                 ], context=context)
1256                 if message_ids:
1257                     mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1258         return result
1259
1260     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1261                         subtype=None, parent_id=False, attachments=None, context=None,
1262                         content_subtype='html', **kwargs):
1263         """ Post a new message in an existing thread, returning the new
1264             mail.message ID.
1265
1266             :param int thread_id: thread ID to post into, or list with one ID;
1267                 if False/0, mail.message model will also be set as False
1268             :param str body: body of the message, usually raw HTML that will
1269                 be sanitized
1270             :param str type: see mail_message.type field
1271             :param str content_subtype:: if plaintext: convert body into html
1272             :param int parent_id: handle reply to a previous message by adding the
1273                 parent partners to the message in case of private discussion
1274             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1275                 ``(name,content)``, where content is NOT base64 encoded
1276
1277             Extra keyword arguments will be used as default column values for the
1278             new mail.message record. Special cases:
1279                 - attachment_ids: supposed not attached to any document; attach them
1280                     to the related document. Should only be set by Chatter.
1281             :return int: ID of newly created mail.message
1282         """
1283         if context is None:
1284             context = {}
1285         if attachments is None:
1286             attachments = {}
1287         mail_message = self.pool.get('mail.message')
1288         ir_attachment = self.pool.get('ir.attachment')
1289
1290         assert (not thread_id) or \
1291                 isinstance(thread_id, (int, long)) or \
1292                 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1293                 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1294         if isinstance(thread_id, (list, tuple)):
1295             thread_id = thread_id[0]
1296
1297         # if we're processing a message directly coming from the gateway, the destination model was
1298         # set in the context.
1299         model = False
1300         if thread_id:
1301             model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1302             if model != self._name and hasattr(self.pool[model], 'message_post'):
1303                 del context['thread_model']
1304                 return self.pool[model].message_post(cr, uid, thread_id, body=body, subject=subject, type=type, subtype=subtype, parent_id=parent_id, attachments=attachments, context=context, content_subtype=content_subtype, **kwargs)
1305
1306         #0: Find the message's author, because we need it for private discussion
1307         author_id = kwargs.get('author_id')
1308         if author_id is None:  # keep False values
1309             author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1310
1311         # 1: Handle content subtype: if plaintext, converto into HTML
1312         if content_subtype == 'plaintext':
1313             body = tools.plaintext2html(body)
1314
1315         # 2: Private message: add recipients (recipients and author of parent message) - current author
1316         #   + legacy-code management (! we manage only 4 and 6 commands)
1317         partner_ids = set()
1318         kwargs_partner_ids = kwargs.pop('partner_ids', [])
1319         for partner_id in kwargs_partner_ids:
1320             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1321                 partner_ids.add(partner_id[1])
1322             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1323                 partner_ids |= set(partner_id[2])
1324             elif isinstance(partner_id, (int, long)):
1325                 partner_ids.add(partner_id)
1326             else:
1327                 pass  # we do not manage anything else
1328         if parent_id and not model:
1329             parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1330             private_followers = set([partner.id for partner in parent_message.partner_ids])
1331             if parent_message.author_id:
1332                 private_followers.add(parent_message.author_id.id)
1333             private_followers -= set([author_id])
1334             partner_ids |= private_followers
1335
1336         # 3. Attachments
1337         #   - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1338         attachment_ids = kwargs.pop('attachment_ids', []) or []  # because we could receive None (some old code sends None)
1339         if attachment_ids:
1340             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1341                 ('res_model', '=', 'mail.compose.message'),
1342                 ('create_uid', '=', uid),
1343                 ('id', 'in', attachment_ids)], context=context)
1344             if filtered_attachment_ids:
1345                 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1346         attachment_ids = [(4, id) for id in attachment_ids]
1347         # Handle attachments parameter, that is a dictionary of attachments
1348         for name, content in attachments:
1349             if isinstance(content, unicode):
1350                 content = content.encode('utf-8')
1351             data_attach = {
1352                 'name': name,
1353                 'datas': base64.b64encode(str(content)),
1354                 'datas_fname': name,
1355                 'description': name,
1356                 'res_model': model,
1357                 'res_id': thread_id,
1358             }
1359             attachment_ids.append((0, 0, data_attach))
1360
1361         # 4: mail.message.subtype
1362         subtype_id = False
1363         if subtype:
1364             if '.' not in subtype:
1365                 subtype = 'mail.%s' % subtype
1366             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1367             subtype_id = ref and ref[1] or False
1368
1369         # automatically subscribe recipients if asked to
1370         if context.get('mail_post_autofollow') and thread_id and partner_ids:
1371             partner_to_subscribe = partner_ids
1372             if context.get('mail_post_autofollow_partner_ids'):
1373                 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1374             self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1375
1376         # _mail_flat_thread: automatically set free messages to the first posted message
1377         if self._mail_flat_thread and not parent_id and thread_id:
1378             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1379             parent_id = message_ids and message_ids[0] or False
1380         # we want to set a parent: force to set the parent_id to the oldest ancestor, to avoid having more than 1 level of thread
1381         elif parent_id:
1382             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1383             # avoid loops when finding ancestors
1384             processed_list = []
1385             if message_ids:
1386                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1387                 while (message.parent_id and message.parent_id.id not in processed_list):
1388                     processed_list.append(message.parent_id.id)
1389                     message = message.parent_id
1390                 parent_id = message.id
1391
1392         values = kwargs
1393         values.update({
1394             'author_id': author_id,
1395             'model': model,
1396             'res_id': thread_id or False,
1397             'body': body,
1398             'subject': subject or False,
1399             'type': type,
1400             'parent_id': parent_id,
1401             'attachment_ids': attachment_ids,
1402             'subtype_id': subtype_id,
1403             'partner_ids': [(4, pid) for pid in partner_ids],
1404         })
1405
1406         # Avoid warnings about non-existing fields
1407         for x in ('from', 'to', 'cc'):
1408             values.pop(x, None)
1409
1410         # Create and auto subscribe the author
1411         msg_id = mail_message.create(cr, uid, values, context=context)
1412         message = mail_message.browse(cr, uid, msg_id, context=context)
1413         if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1414             self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1415         return msg_id
1416
1417     #------------------------------------------------------
1418     # Followers API
1419     #------------------------------------------------------
1420
1421     def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1422         """ Wrapper to get subtypes data. """
1423         return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1424
1425     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1426         """ Wrapper on message_subscribe, using users. If user_ids is not
1427             provided, subscribe uid instead. """
1428         if user_ids is None:
1429             user_ids = [uid]
1430         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1431         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1432
1433     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1434         """ Add partners to the records followers. """
1435         mail_followers_obj = self.pool.get('mail.followers')
1436         subtype_obj = self.pool.get('mail.message.subtype')
1437
1438         user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1439         if set(partner_ids) == set([user_pid]):
1440             try:
1441                 self.check_access_rights(cr, uid, 'read')
1442             except (osv.except_osv, orm.except_orm):
1443                 return
1444         else:
1445             self.check_access_rights(cr, uid, 'write')
1446
1447         for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1448             existing_pids = set([f.id for f in record.message_follower_ids
1449                                             if f.id in partner_ids])
1450             new_pids = set(partner_ids) - existing_pids
1451
1452             # subtype_ids specified: update already subscribed partners
1453             if subtype_ids and existing_pids:
1454                 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1455                                                         ('res_model', '=', self._name),
1456                                                         ('res_id', '=', record.id),
1457                                                         ('partner_id', 'in', list(existing_pids)),
1458                                                     ], context=context)
1459                 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1460             # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1461             elif subtype_ids is None:
1462                 subtype_ids = subtype_obj.search(cr, uid, [
1463                                                         ('default', '=', True),
1464                                                         '|',
1465                                                         ('res_model', '=', self._name),
1466                                                         ('res_model', '=', False)
1467                                                     ], context=context)
1468             # subscribe new followers
1469             for new_pid in new_pids:
1470                 mail_followers_obj.create(cr, SUPERUSER_ID, {
1471                                                 'res_model': self._name,
1472                                                 'res_id': record.id,
1473                                                 'partner_id': new_pid,
1474                                                 'subtype_ids': [(6, 0, subtype_ids)],
1475                                             }, context=context)
1476
1477         return True
1478
1479     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1480         """ Wrapper on message_subscribe, using users. If user_ids is not
1481             provided, unsubscribe uid instead. """
1482         if user_ids is None:
1483             user_ids = [uid]
1484         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1485         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1486
1487     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1488         """ Remove partners from the records followers. """
1489         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1490         if set(partner_ids) == set([user_pid]):
1491             self.check_access_rights(cr, uid, 'read')
1492         else:
1493             self.check_access_rights(cr, uid, 'write')
1494         return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1495
1496     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1497         """ Returns the list of relational fields linking to res.users that should
1498             trigger an auto subscribe. The default list checks for the fields
1499             - called 'user_id'
1500             - linking to res.users
1501             - with track_visibility set
1502             In OpenERP V7, this is sufficent for all major addon such as opportunity,
1503             project, issue, recruitment, sale.
1504             Override this method if a custom behavior is needed about fields
1505             that automatically subscribe users.
1506         """
1507         user_field_lst = []
1508         for name, column_info in self._all_columns.items():
1509             if name in auto_follow_fields and name in updated_fields and getattr(column_info.column, 'track_visibility', False) and column_info.column._obj == 'res.users':
1510                 user_field_lst.append(name)
1511         return user_field_lst
1512
1513     def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1514         """
1515             1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1516             2. for each project subtype: subscribe the follower to the task
1517         """
1518         subtype_obj = self.pool.get('mail.message.subtype')
1519         follower_obj = self.pool.get('mail.followers')
1520
1521         # fetch auto_follow_fields
1522         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1523
1524         # fetch related record subtypes
1525         related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1526         subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1527         default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1528         related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1529         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1530         if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1531             return True
1532
1533         for record in self.browse(cr, uid, ids, context=context):
1534             new_followers = dict()
1535             parent_res_id = False
1536             parent_model = False
1537             for subtype in related_subtypes:
1538                 if not subtype.relation_field or not subtype.parent_id:
1539                     continue
1540                 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1541                     continue
1542                 parent_res_id = getattr(record, subtype.relation_field).id
1543                 parent_model = subtype.res_model
1544                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1545                     ('res_model', '=', parent_model),
1546                     ('res_id', '=', parent_res_id),
1547                     ('subtype_ids', 'in', [subtype.id])
1548                     ], context=context)
1549                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1550                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1551
1552             if parent_res_id and parent_model:
1553                 for subtype in default_subtypes:
1554                     follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1555                         ('res_model', '=', parent_model),
1556                         ('res_id', '=', parent_res_id),
1557                         ('subtype_ids', 'in', [subtype.id])
1558                         ], context=context)
1559                     for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1560                         new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1561
1562             # add followers coming from res.users relational fields that are tracked
1563             user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1564             user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1565             for partner_id in user_id_partner_ids:
1566                 new_followers.setdefault(partner_id, None)
1567
1568             for pid, subtypes in new_followers.items():
1569                 subtypes = list(subtypes) if subtypes is not None else None
1570                 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1571
1572             # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1573             if user_id_partner_ids:
1574                 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1575                                 ('model', '=', self._name),
1576                                 ('res_id', '=', record.id),
1577                                 ('type', '=', 'email')], limit=1, context=context)
1578                 if not msg_ids and record.message_ids:
1579                     msg_ids = [record.message_ids[-1].id]
1580                 if msg_ids:
1581                     self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1582
1583         return True
1584
1585     #------------------------------------------------------
1586     # Thread state
1587     #------------------------------------------------------
1588
1589     def message_mark_as_unread(self, cr, uid, ids, context=None):
1590         """ Set as unread. """
1591         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1592         cr.execute('''
1593             UPDATE mail_notification SET
1594                 read=false
1595             WHERE
1596                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1597                 partner_id = %s
1598         ''', (ids, self._name, partner_id))
1599         return True
1600
1601     def message_mark_as_read(self, cr, uid, ids, context=None):
1602         """ Set as read. """
1603         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1604         cr.execute('''
1605             UPDATE mail_notification SET
1606                 read=true
1607             WHERE
1608                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1609                 partner_id = %s
1610         ''', (ids, self._name, partner_id))
1611         return True
1612
1613     #------------------------------------------------------
1614     # Thread suggestion
1615     #------------------------------------------------------
1616
1617     def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1618         """Return a list of suggested threads, sorted by the numbers of followers"""
1619         if context is None:
1620             context = {}
1621
1622         # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1623         # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1624         if self.pool['res.groups']._all_columns.get('is_portal'):
1625             user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1626             if any(group.is_portal for group in user.groups_id):
1627                 return []
1628
1629         threads = []
1630         if removed_suggested_threads is None:
1631             removed_suggested_threads = []
1632
1633         thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1634         for thread in self.browse(cr, uid, thread_ids, context=context):
1635             data = {
1636                 'id': thread.id,
1637                 'popularity': len(thread.message_follower_ids),
1638                 'name': thread.name,
1639                 'image_small': thread.image_small
1640             }
1641             threads.append(data)
1642         return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]