1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
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
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
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/>
20 ##############################################################################
23 from collections import OrderedDict
28 import simplejson as json
31 from lxml import etree
36 from email.message import Message
38 from openerp import tools
39 from openerp import SUPERUSER_ID
40 from openerp.addons.mail.mail_message import decode
41 from openerp.osv import fields, osv, orm
42 from openerp.osv.orm import browse_record, browse_null
43 from openerp.tools.safe_eval import safe_eval as eval
44 from openerp.tools.translate import _
46 _logger = logging.getLogger(__name__)
49 def decode_header(message, header, separator=' '):
50 return separator.join(map(decode, filter(None, message.get_all(header, []))))
53 class mail_thread(osv.AbstractModel):
54 ''' mail_thread model is meant to be inherited by any model that needs to
55 act as a discussion topic on which messages can be attached. Public
56 methods are prefixed with ``message_`` in order to avoid name
57 collisions with methods of the models that will inherit from this class.
59 ``mail.thread`` defines fields used to handle and display the
60 communication history. ``mail.thread`` also manages followers of
61 inheriting classes. All features and expected behavior are managed
62 by mail.thread. Widgets has been designed for the 7.0 and following
65 Inheriting classes are not required to implement any method, as the
66 default implementation will work for any model. However it is common
67 to override at least the ``message_new`` and ``message_update``
68 methods (calling ``super``) to add model-specific behavior at
69 creation and update of a thread when processing incoming emails.
72 - _mail_flat_thread: if set to True, all messages without parent_id
73 are automatically attached to the first message posted on the
74 ressource. If set to False, the display of Chatter is done using
75 threads, and no parent_id is automatically set.
78 _description = 'Email Thread'
79 _mail_flat_thread = True
80 _mail_post_access = 'write'
82 # Automatic logging system if mail installed
85 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
86 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
93 # :param string field: field name
94 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
95 # :param obj: is a browse_record
96 # :param function lambda: returns whether the tracking should record using this subtype
99 def get_empty_list_help(self, cr, uid, help, context=None):
100 """ Override of BaseModel.get_empty_list_help() to generate an help message
101 that adds alias information. """
102 model = context.get('empty_list_help_model')
103 res_id = context.get('empty_list_help_id')
104 ir_config_parameter = self.pool.get("ir.config_parameter")
105 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
106 document_name = context.get('empty_list_help_document_name', _('document'))
109 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
110 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
111 # check that the alias effectively creates new records
112 if object_id.alias_id and object_id.alias_id.alias_name and \
113 object_id.alias_id.alias_model_id and \
114 object_id.alias_id.alias_model_id.model == self._name and \
115 object_id.alias_id.alias_force_thread_id == 0:
116 alias = object_id.alias_id
117 if not alias and catchall_domain and model: # no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model
118 alias_obj = self.pool.get('mail.alias')
119 alias_ids = alias_obj.search(cr, uid, [("alias_parent_model_id.model", "=", model), ("alias_name", "!=", False), ('alias_force_thread_id', '=', False), ('alias_parent_thread_id', '=', False)], context=context, order='id ASC')
120 if alias_ids and len(alias_ids) == 1:
121 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
124 alias_email = alias.name_get()[0][1]
125 return _("""<p class='oe_view_nocontent_create'>
126 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
130 'document': document_name,
131 'email': alias_email,
132 'static_help': help or ''
135 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
136 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
137 'document': document_name,
138 'static_help': help or '',
143 def _get_message_data(self, cr, uid, ids, name, args, context=None):
145 - message_unread: has uid unread message for the document
146 - message_summary: html snippet summarizing the Chatter for kanban views """
147 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
148 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
150 # search for unread messages, directly in SQL to improve performances
151 cr.execute(""" SELECT m.res_id FROM mail_message m
152 RIGHT JOIN mail_notification n
153 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
154 WHERE m.model = %s AND m.res_id in %s""",
155 (user_pid, self._name, tuple(ids),))
156 for result in cr.fetchall():
157 res[result[0]]['message_unread'] = True
158 res[result[0]]['message_unread_count'] += 1
161 if res[id]['message_unread_count']:
162 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
163 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"))
164 res[id].pop('message_unread_count', None)
167 def read_followers_data(self, cr, uid, follower_ids, context=None):
169 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
170 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
171 is_editable = uid in map(lambda x: x.id, technical_group.users)
172 is_uid = uid in map(lambda x: x.id, follower.user_ids)
175 {'is_editable': is_editable, 'is_uid': is_uid},
180 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
182 - message_subtype_data: data about document subtypes: which are
183 available, which are followed if any """
184 res = dict((id, dict(message_subtype_data='')) for id in ids)
186 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
188 # find current model subtypes, add them to a dictionary
189 subtype_obj = self.pool.get('mail.message.subtype')
190 subtype_ids = subtype_obj.search(
192 '&', ('hidden', '=', False), '|', ('res_model', '=', self._name), ('res_model', '=', False)
194 subtype_dict = OrderedDict(
196 'default': subtype.default,
198 'parent_model': subtype.parent_id and subtype.parent_id.res_model or self._name,
200 ) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
202 res[id]['message_subtype_data'] = subtype_dict.copy()
204 # find the document followers, update the data
205 fol_obj = self.pool.get('mail.followers')
206 fol_ids = fol_obj.search(cr, uid, [
207 ('partner_id', '=', user_pid),
208 ('res_id', 'in', ids),
209 ('res_model', '=', self._name),
211 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
212 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
213 for subtype in [st for st in fol.subtype_ids if st.name in thread_subtype_dict]:
214 thread_subtype_dict[subtype.name]['followed'] = True
215 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
219 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
220 return [('message_ids.to_read', '=', True)]
222 def _get_followers(self, cr, uid, ids, name, arg, context=None):
223 fol_obj = self.pool.get('mail.followers')
224 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
225 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
226 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
227 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
228 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
229 if fol.partner_id.id == user_pid:
230 res[fol.res_id]['message_is_follower'] = True
233 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
236 partner_obj = self.pool.get('res.partner')
237 fol_obj = self.pool.get('mail.followers')
239 # read the old set of followers, and determine the new set of followers
240 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
241 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
244 for command in value or []:
245 if isinstance(command, (int, long)):
247 elif command[0] == 0:
248 new.add(partner_obj.create(cr, uid, command[2], context=context))
249 elif command[0] == 1:
250 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
252 elif command[0] == 2:
253 partner_obj.unlink(cr, uid, [command[1]], context=context)
254 new.discard(command[1])
255 elif command[0] == 3:
256 new.discard(command[1])
257 elif command[0] == 4:
259 elif command[0] == 5:
261 elif command[0] == 6:
262 new = set(command[2])
264 # remove partners that are no longer followers
265 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
267 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
269 def _search_followers(self, cr, uid, obj, name, args, context):
270 """Search function for message_follower_ids
272 Do not use with operator 'not in'. Use instead message_is_followers
274 fol_obj = self.pool.get('mail.followers')
276 for field, operator, value in args:
278 # TOFIX make it work with not in
279 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
280 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
281 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
282 res.append(('id', 'in', res_ids))
285 def _search_is_follower(self, cr, uid, obj, name, args, context):
286 """Search function for message_is_follower"""
288 for field, operator, value in args:
290 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
291 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
292 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
293 else: # is not a follower or unknown domain
294 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
295 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
296 res.append(('id', 'in', res_ids))
300 'message_is_follower': fields.function(_get_followers, type='boolean',
301 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
302 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
303 fnct_search=_search_followers, type='many2many', priority=-10,
304 obj='res.partner', string='Followers', multi='_get_followers'),
305 'message_ids': fields.one2many('mail.message', 'res_id',
306 domain=lambda self: [('model', '=', self._name)],
309 help="Messages and communication history"),
310 'message_last_post': fields.datetime('Last Message Date',
311 help='Date of the last message posted on the record.'),
312 'message_unread': fields.function(_get_message_data,
313 fnct_search=_search_message_unread, multi="_get_message_data",
314 type='boolean', string='Unread Messages',
315 help="If checked new messages require your attention."),
316 'message_summary': fields.function(_get_message_data, method=True,
317 type='text', string='Summary', multi="_get_message_data",
318 help="Holds the Chatter summary (number of messages, ...). "\
319 "This summary is directly in html format in order to "\
320 "be inserted in kanban views."),
323 def _get_user_chatter_options(self, cr, uid, context=None):
325 'display_log_button': False
327 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
328 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
329 is_employee = group_user_id in [group.id for group in group_ids]
331 options['display_log_button'] = True
334 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
335 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
336 if view_type == 'form':
337 doc = etree.XML(res['arch'])
338 for node in doc.xpath("//field[@name='message_ids']"):
339 options = json.loads(node.get('options', '{}'))
340 options.update(self._get_user_chatter_options(cr, uid, context=context))
341 node.set('options', json.dumps(options))
342 res['arch'] = etree.tostring(doc)
345 #------------------------------------------------------
346 # CRUD overrides for automatic subscription and logging
347 #------------------------------------------------------
349 def create(self, cr, uid, values, context=None):
350 """ Chatter override :
352 - subscribe followers of parent
353 - log a creation message
358 # subscribe uid unless asked not to
359 if not context.get('mail_create_nosubscribe'):
360 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
361 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
362 message_follower_ids.append([4, pid])
363 values['message_follower_ids'] = message_follower_ids
364 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
366 # automatic logging unless asked not to (mainly for various testing purpose)
367 if not context.get('mail_create_nolog'):
368 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
370 # auto_subscribe: take values and defaults into account
371 create_values = dict(values)
372 for key, val in context.iteritems():
373 if key.startswith('default_'):
374 create_values[key[8:]] = val
375 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
378 track_ctx = dict(context)
379 if 'lang' not in track_ctx:
380 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
381 if not context.get('mail_notrack'):
382 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
384 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
385 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
388 def write(self, cr, uid, ids, values, context=None):
391 if isinstance(ids, (int, long)):
393 # Track initial values of tracked fields
394 track_ctx = dict(context)
395 if 'lang' not in track_ctx:
396 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
397 if not context.get('mail_notrack'):
398 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
402 records = self.browse(cr, uid, ids, context=track_ctx)
403 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
405 # Perform write, update followers
406 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
407 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
409 if not context.get('mail_notrack'):
410 # Perform the tracking
411 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
413 tracked_fields = None
415 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
418 def unlink(self, cr, uid, ids, context=None):
419 """ Override unlink to delete messages and followers. This cannot be
420 cascaded, because link is done through (res_model, res_id). """
421 msg_obj = self.pool.get('mail.message')
422 fol_obj = self.pool.get('mail.followers')
423 # delete messages and notifications
424 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
425 msg_obj.unlink(cr, uid, msg_ids, context=context)
427 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
429 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
430 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
433 def copy(self, cr, uid, id, default=None, context=None):
434 # avoid tracking multiple temporary changes during copy
435 context = dict(context or {}, mail_notrack=True)
437 default = default or {}
438 default['message_ids'] = []
439 default['message_follower_ids'] = []
440 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
442 #------------------------------------------------------
443 # Automatically log tracked fields
444 #------------------------------------------------------
446 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
447 """ Return a structure of tracked fields for the current model.
448 :param list updated_fields: modified field names
449 :return list: a list of (field_name, column_info obj), containing
450 always tracked fields and modified on_change fields
453 for name, column_info in self._all_columns.items():
454 visibility = getattr(column_info.column, 'track_visibility', False)
455 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
459 return self.fields_get(cr, uid, lst, context=context)
461 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
463 def convert_for_display(value, col_info):
464 if not value and col_info['type'] == 'boolean':
468 if col_info['type'] == 'many2one':
469 return value.name_get()[0][1]
470 if col_info['type'] == 'selection':
471 return dict(col_info['selection'])[value]
474 def format_message(message_description, tracked_values):
476 if message_description:
477 message = '<span>%s</span>' % message_description
478 for name, change in tracked_values.items():
479 message += '<div> • <b>%s</b>: ' % change.get('col_info')
480 if change.get('old_value'):
481 message += '%s → ' % change.get('old_value')
482 message += '%s</div>' % change.get('new_value')
485 if not tracked_fields:
488 for browse_record in self.browse(cr, uid, ids, context=context):
489 initial = initial_values[browse_record.id]
493 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
494 for col_name, col_info in tracked_fields.items():
495 initial_value = initial[col_name]
496 record_value = getattr(browse_record, col_name)
498 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
499 tracked_values[col_name] = dict(col_info=col_info['string'],
500 new_value=convert_for_display(record_value, col_info))
501 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
502 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
503 tracked_values[col_name] = dict(col_info=col_info['string'],
504 old_value=convert_for_display(initial_value, col_info),
505 new_value=convert_for_display(record_value, col_info))
506 if col_name in tracked_fields:
507 changes.add(col_name)
511 # find subtypes and post messages or log if no subtype found
513 for field, track_info in self._track.items():
514 if field not in changes:
516 for subtype, method in track_info.items():
517 if method(self, cr, uid, browse_record, context):
518 subtypes.append(subtype)
521 for subtype in subtypes:
522 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
523 if not (subtype_rec and subtype_rec.exists()):
524 _logger.debug('subtype %s not found' % subtype)
526 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
527 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
530 message = format_message('', tracked_values)
531 self.message_post(cr, uid, browse_record.id, body=message, context=context)
534 #------------------------------------------------------
535 # mail.message wrappers and tools
536 #------------------------------------------------------
538 def _needaction_domain_get(self, cr, uid, context=None):
540 return [('message_unread', '=', True)]
543 def _garbage_collect_attachments(self, cr, uid, context=None):
544 """ Garbage collect lost mail attachments. Those are attachments
545 - linked to res_model 'mail.compose.message', the composer wizard
546 - with res_id 0, because they were created outside of an existing
547 wizard (typically user input through Chatter or reports
548 created on-the-fly by the templates)
549 - unused since at least one day (create_date and write_date)
551 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
552 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
553 ir_attachment_obj = self.pool.get('ir.attachment')
554 attach_ids = ir_attachment_obj.search(cr, uid, [
555 ('res_model', '=', 'mail.compose.message'),
557 ('create_date', '<', limit_date_str),
558 ('write_date', '<', limit_date_str),
560 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
563 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
564 """ mail.message check permission rules for related document. This method is
565 meant to be inherited in order to implement addons-specific behavior.
566 A common behavior would be to allow creating messages when having read
567 access rule on the document, for portal document such as issues. """
570 if hasattr(self, '_mail_post_access'):
571 create_allow = self._mail_post_access
573 create_allow = 'write'
575 if operation in ['write', 'unlink']:
576 check_operation = 'write'
577 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
578 check_operation = create_allow
579 elif operation == 'create':
580 check_operation = 'write'
582 check_operation = operation
584 model_obj.check_access_rights(cr, uid, check_operation)
585 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
587 def _get_formview_action(self, cr, uid, id, model=None, context=None):
588 """ Return an action to open the document. This method is meant to be
589 overridden in addons that want to give specific view ids for example.
591 :param int id: id of the document to open
592 :param string model: specific model that overrides self._name
595 'type': 'ir.actions.act_window',
596 'res_model': model or self._name,
599 'views': [(False, 'form')],
604 def _get_inbox_action_xml_id(self, cr, uid, context=None):
605 """ When redirecting towards the Inbox, choose which action xml_id has
606 to be fetched. This method is meant to be inherited, at least in portal
607 because portal users have a different Inbox action than classic users. """
608 return ('mail', 'action_mail_inbox_feeds')
610 def message_redirect_action(self, cr, uid, context=None):
611 """ For a given message, return an action that either
612 - opens the form view of the related document if model, res_id, and
613 read access to the document
614 - opens the Inbox with a default search on the conversation if model,
616 - opens the Inbox with context propagated
622 # default action is the Inbox action
623 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
624 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))
625 action = self.pool.get(act_model).read(cr, uid, act_id, [])
626 params = context.get('params')
627 msg_id = model = res_id = None
630 msg_id = params.get('message_id')
631 model = params.get('model')
632 res_id = params.get('res_id')
633 if not msg_id and not (model and res_id):
635 if msg_id and not (model and res_id):
636 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
638 model, res_id = msg.model, msg.res_id
640 # if model + res_id found: try to redirect to the document or fallback on the Inbox
642 model_obj = self.pool.get(model)
643 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
645 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
646 if not hasattr(model_obj, '_get_formview_action'):
647 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
649 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
650 except (osv.except_osv, orm.except_orm):
654 'search_default_model': model,
655 'search_default_res_id': res_id,
660 #------------------------------------------------------
662 #------------------------------------------------------
664 def message_get_reply_to(self, cr, uid, ids, context=None):
665 """ Returns the preferred reply-to email address that is basically
666 the alias of the document, if it exists. """
667 if not self._inherits.get('mail.alias'):
668 return [False for id in ids]
669 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
670 if record.get('alias_domain') and record.get('alias_name')
672 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
674 #------------------------------------------------------
676 #------------------------------------------------------
678 def message_capable_models(self, cr, uid, context=None):
679 """ Used by the plugin addon, based for plugin_outlook and others. """
681 for model_name in self.pool.obj_list():
682 model = self.pool[model_name]
683 if hasattr(model, "message_process") and hasattr(model, "message_post"):
684 ret_dict[model_name] = model._description
687 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
688 """ Find partners related to some header fields of the message.
690 :param string message: an email.message instance """
691 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
692 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
694 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
695 """ Verify route validity. Check and rules:
696 1 - if thread_id -> check that document effectively exists; otherwise
697 fallback on a message_new by resetting thread_id
698 2 - check that message_update exists if thread_id is set; or at least
699 that message_new exist
700 [ - find author_id if udpate_author is set]
701 3 - if there is an alias, check alias_contact:
702 'followers' and thread_id:
703 check on target document that the author is in the followers
704 'followers' and alias_parent_thread_id:
705 check on alias parent document that the author is in the
707 'partners': check that author_id id set
710 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
711 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
713 message_id = message.get('Message-Id')
714 email_from = decode_header(message, 'From')
715 author_id = message_dict.get('author_id')
716 model, thread_id, alias = route[0], route[1], route[4]
719 def _create_bounce_email():
720 mail_mail = self.pool.get('mail.mail')
721 mail_id = mail_mail.create(cr, uid, {
722 'body_html': '<div><p>Hello,</p>'
723 '<p>The following email sent to %s cannot be accepted because this is '
724 'a private email address. Only allowed people can contact us at this address.</p></div>'
725 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
726 'subject': 'Re: %s' % message.get('subject'),
727 'email_to': message.get('from'),
730 mail_mail.send(cr, uid, [mail_id], context=context)
733 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
734 message_id, route, message)
737 if model and not model in self.pool:
739 assert model in self.pool, 'Routing: unknown target model %s' % model
740 _warn('unknown target model %s' % model)
743 model_pool = self.pool[model]
745 # Private message: should not contain any thread_id
746 if not model and thread_id:
749 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
750 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
752 # Private message: should have a parent_id (only answers)
753 if not model and not message_dict.get('parent_id'):
755 if not message_dict.get('parent_id'):
756 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
757 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
760 # Existing Document: check if exists; if not, fallback on create if allowed
761 if thread_id and not model_pool.exists(cr, uid, thread_id):
763 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
766 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
768 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
771 # Existing Document: check model accepts the mailgateway
772 if thread_id and model and not hasattr(model_pool, 'message_update'):
774 _warn('model %s does not accept document update, fall back on document creation' % model)
777 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
779 _warn('model %s does not accept document update, skipping' % model)
782 # New Document: check model accepts the mailgateway
783 if not thread_id and model and not hasattr(model_pool, 'message_new'):
785 if not hasattr(model_pool, 'message_new'):
787 'Model %s does not accept document creation, crashing' % model
789 _warn('model %s does not accept document creation, skipping' % model)
792 # Update message author if asked
793 # We do it now because we need it for aliases (contact settings)
794 if not author_id and update_author:
795 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
797 author_id = author_ids[0]
798 message_dict['author_id'] = author_id
800 # Alias: check alias_contact settings
801 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
803 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
805 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
806 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
807 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
808 _create_bounce_email()
810 elif alias and alias.alias_contact == 'partners' and not author_id:
811 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
812 _create_bounce_email()
815 return (model, thread_id, route[2], route[3], route[4])
817 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
818 custom_values=None, context=None):
819 """Attempt to figure out the correct target model, thread_id,
820 custom_values and user_id to use for an incoming message.
821 Multiple values may be returned, if a message had multiple
822 recipients matching existing mail.aliases, for example.
824 The following heuristics are used, in this order:
825 1. If the message replies to an existing thread_id, and
826 properly contains the thread model in the 'In-Reply-To'
827 header, use this model/thread_id pair, and ignore
828 custom_value (not needed as no creation will take place)
829 2. Look for a mail.alias entry matching the message
830 recipient, and use the corresponding model, thread_id,
831 custom_values and user_id.
832 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
834 4. If all the above fails, raise an exception.
836 :param string message: an email.message instance
837 :param dict message_dict: dictionary holding message variables
838 :param string model: the fallback model to use if the message
839 does not match any of the currently configured mail aliases
840 (may be None if a matching alias is supposed to be present)
841 :type dict custom_values: optional dictionary of default field values
842 to pass to ``message_new`` if a new record needs to be created.
843 Ignored if the thread record already exists, and also if a
844 matching mail.alias was found (aliases define their own defaults)
845 :param int thread_id: optional ID of the record/thread from ``model``
846 to which this mail should be attached. Only used if the message
847 does not reply to an existing thread and does not match any mail alias.
848 :return: list of [model, thread_id, custom_values, user_id, alias]
850 :raises: ValueError, TypeError
852 if not isinstance(message, Message):
853 raise TypeError('message must be an email.message.Message at this point')
854 mail_msg_obj = self.pool['mail.message']
855 fallback_model = model
857 # Get email.message.Message variables for future processing
858 message_id = message.get('Message-Id')
859 email_from = decode_header(message, 'From')
860 email_to = decode_header(message, 'To')
861 references = decode_header(message, 'References')
862 in_reply_to = decode_header(message, 'In-Reply-To')
863 thread_references = references or in_reply_to
865 # 1. message is a reply to an existing message (exact match of message_id)
866 msg_references = thread_references.split()
867 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
869 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
870 model, thread_id = original_msg.model, original_msg.res_id
872 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
873 email_from, email_to, message_id, model, thread_id, custom_values, uid)
874 route = self.message_route_verify(
875 cr, uid, message, message_dict,
876 (model, thread_id, custom_values, uid, None),
877 update_author=True, assert_model=True, create_fallback=True, context=context)
878 return route and [route] or []
880 # 2. message is a reply to an existign thread (6.1 compatibility)
881 ref_match = thread_references and tools.reference_re.search(thread_references)
883 thread_id = int(ref_match.group(1))
884 model = ref_match.group(2) or fallback_model
885 if thread_id and model in self.pool:
886 model_obj = self.pool[model]
887 compat_mail_msg_ids = mail_msg_obj.search(
889 ('message_id', '=', False),
890 ('model', '=', model),
891 ('res_id', '=', thread_id),
893 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
895 'Routing mail from %s to %s with Message-Id %s: direct thread reply (compat-mode) to model: %s, thread_id: %s, custom_values: %s, uid: %s',
896 email_from, email_to, message_id, model, thread_id, custom_values, uid)
897 route = self.message_route_verify(
898 cr, uid, message, message_dict,
899 (model, thread_id, custom_values, uid, None),
900 update_author=True, assert_model=True, create_fallback=True, context=context)
901 return route and [route] or []
903 # 2. Reply to a private message
905 mail_message_ids = mail_msg_obj.search(cr, uid, [
906 ('message_id', '=', in_reply_to),
907 '!', ('message_id', 'ilike', 'reply_to')
908 ], limit=1, context=context)
910 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
911 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
912 email_from, email_to, message_id, mail_message.id, custom_values, uid)
913 route = self.message_route_verify(cr, uid, message, message_dict,
914 (mail_message.model, mail_message.res_id, custom_values, uid, None),
915 update_author=True, assert_model=True, create_fallback=True, context=context)
916 return route and [route] or []
918 # 3. Look for a matching mail.alias entry
919 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
920 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
922 ','.join([decode_header(message, 'Delivered-To'),
923 decode_header(message, 'To'),
924 decode_header(message, 'Cc'),
925 decode_header(message, 'Resent-To'),
926 decode_header(message, 'Resent-Cc')])
927 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
929 mail_alias = self.pool.get('mail.alias')
930 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
933 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
934 user_id = alias.alias_user_id.id
936 # TDE note: this could cause crashes, because no clue that the user
937 # that send the email has the right to create or modify a new document
938 # Fallback on user_id = uid
939 # Note: recognized partners will be added as followers anyway
940 # user_id = self._message_find_user_id(cr, uid, message, context=context)
942 _logger.info('No matching user_id for the alias %s', alias.alias_name)
943 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
944 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
945 email_from, email_to, message_id, route)
946 route = self.message_route_verify(cr, uid, message, message_dict, route,
947 update_author=True, assert_model=True, create_fallback=True, context=context)
952 # 4. Fallback to the provided parameters, if they work
954 # Legacy: fallback to matching [ID] in the Subject
955 match = tools.res_re.search(decode_header(message, 'Subject'))
956 thread_id = match and match.group(1)
957 # Convert into int (bug spotted in 7.0 because of str)
959 thread_id = int(thread_id)
962 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
963 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
964 route = self.message_route_verify(cr, uid, message, message_dict,
965 (fallback_model, thread_id, custom_values, uid, None),
966 update_author=True, assert_model=True, context=context)
970 # AssertionError if no routes found and if no bounce occured
972 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
973 'Create an appropriate mail.alias or force the destination model.' %
974 (email_from, email_to, message_id)
977 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
978 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
979 partner_ids = message_dict.pop('partner_ids', [])
981 for model, thread_id, custom_values, user_id, alias in routes:
982 if self._name == 'mail.thread':
983 context.update({'thread_model': model})
985 model_pool = self.pool[model]
986 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
988 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
989 (message_dict['message_id'], model)
992 # disabled subscriptions during message_new/update to avoid having the system user running the
993 # email gateway become a follower of all inbound messages
994 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
995 if thread_id and hasattr(model_pool, 'message_update'):
996 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
998 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
1001 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
1002 model_pool = self.pool.get('mail.thread')
1003 if not hasattr(model_pool, 'message_post'):
1004 context['thread_model'] = model
1005 model_pool = self.pool['mail.thread']
1006 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1009 # postponed after message_post, because this is an external message and we don't want to create
1010 # duplicate emails due to notifications
1011 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1014 def message_process(self, cr, uid, model, message, custom_values=None,
1015 save_original=False, strip_attachments=False,
1016 thread_id=None, context=None):
1017 """ Process an incoming RFC2822 email message, relying on
1018 ``mail.message.parse()`` for the parsing operation,
1019 and ``message_route()`` to figure out the target model.
1021 Once the target model is known, its ``message_new`` method
1022 is called with the new message (if the thread record did not exist)
1023 or its ``message_update`` method (if it did).
1025 There is a special case where the target model is False: a reply
1026 to a private message. In this case, we skip the message_new /
1027 message_update step, to just post a new message using mail_thread
1030 :param string model: the fallback model to use if the message
1031 does not match any of the currently configured mail aliases
1032 (may be None if a matching alias is supposed to be present)
1033 :param message: source of the RFC2822 message
1034 :type message: string or xmlrpclib.Binary
1035 :type dict custom_values: optional dictionary of field values
1036 to pass to ``message_new`` if a new record needs to be created.
1037 Ignored if the thread record already exists, and also if a
1038 matching mail.alias was found (aliases define their own defaults)
1039 :param bool save_original: whether to keep a copy of the original
1040 email source attached to the message after it is imported.
1041 :param bool strip_attachments: whether to strip all attachments
1042 before processing the message, in order to save some space.
1043 :param int thread_id: optional ID of the record/thread from ``model``
1044 to which this mail should be attached. When provided, this
1045 overrides the automatic detection based on the message
1051 # extract message bytes - we are forced to pass the message as binary because
1052 # we don't know its encoding until we parse its headers and hence can't
1053 # convert it to utf-8 for transport between the mailgate script and here.
1054 if isinstance(message, xmlrpclib.Binary):
1055 message = str(message.data)
1056 # Warning: message_from_string doesn't always work correctly on unicode,
1057 # we must use utf-8 strings here :-(
1058 if isinstance(message, unicode):
1059 message = message.encode('utf-8')
1060 msg_txt = email.message_from_string(message)
1062 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1063 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1064 if strip_attachments:
1065 msg.pop('attachments', None)
1067 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1068 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1069 ('message_id', '=', msg.get('message_id')),
1071 if existing_msg_ids:
1072 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1073 msg.get('from'), msg.get('to'), msg.get('message_id'))
1076 # find possible routes for the message
1077 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1078 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1081 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1082 """Called by ``message_process`` when a new message is received
1083 for a given thread model, if the message did not belong to
1085 The default behavior is to create a new record of the corresponding
1086 model (based on some very basic info extracted from the message).
1087 Additional behavior may be implemented by overriding this method.
1089 :param dict msg_dict: a map containing the email details and
1090 attachments. See ``message_process`` and
1091 ``mail.message.parse`` for details.
1092 :param dict custom_values: optional dictionary of additional
1093 field values to pass to create()
1094 when creating the new thread record.
1095 Be careful, these values may override
1096 any other values coming from the message.
1097 :param dict context: if a ``thread_model`` value is present
1098 in the context, its value will be used
1099 to determine the model of the record
1100 to create (instead of the current model).
1102 :return: the id of the newly created thread object
1107 if isinstance(custom_values, dict):
1108 data = custom_values.copy()
1109 model = context.get('thread_model') or self._name
1110 model_pool = self.pool[model]
1111 fields = model_pool.fields_get(cr, uid, context=context)
1112 if 'name' in fields and not data.get('name'):
1113 data['name'] = msg_dict.get('subject', '')
1114 res_id = model_pool.create(cr, uid, data, context=context)
1117 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1118 """Called by ``message_process`` when a new message is received
1119 for an existing thread. The default behavior is to update the record
1120 with update_vals taken from the incoming email.
1121 Additional behavior may be implemented by overriding this
1123 :param dict msg_dict: a map containing the email details and
1124 attachments. See ``message_process`` and
1125 ``mail.message.parse()`` for details.
1126 :param dict update_vals: a dict containing values to update records
1127 given their ids; if the dict is None or is
1128 void, no write operation is performed.
1131 self.write(cr, uid, ids, update_vals, context=context)
1134 def _message_extract_payload(self, message, save_original=False):
1135 """Extract body as HTML and attachments from the mail message"""
1139 attachments.append(('original_email.eml', message.as_string()))
1140 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1141 encoding = message.get_content_charset()
1142 body = message.get_payload(decode=True)
1143 body = tools.ustr(body, encoding, errors='replace')
1144 if message.get_content_type() == 'text/plain':
1145 # text/plain -> <pre/>
1146 body = tools.append_content_to_html(u'', body, preserve=True)
1149 for part in message.walk():
1150 if part.get_content_type() == 'multipart/alternative':
1152 if part.get_content_maintype() == 'multipart':
1153 continue # skip container
1154 # part.get_filename returns decoded value if able to decode, coded otherwise.
1155 # original get_filename is not able to decode iso-8859-1 (for instance).
1156 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1157 # code here partially copy the original get_filename method, but handle more encoding
1158 filename=part.get_param('filename', None, 'content-disposition')
1160 filename=part.get_param('name', None)
1162 if isinstance(filename, tuple):
1164 filename=email.utils.collapse_rfc2231_value(filename).strip()
1166 filename=decode(filename)
1167 encoding = part.get_content_charset() # None if attachment
1168 # 1) Explicit Attachments -> attachments
1169 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1170 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1172 # 2) text/plain -> <pre/>
1173 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1174 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1175 encoding, errors='replace'), preserve=True)
1176 # 3) text/html -> raw
1177 elif part.get_content_type() == 'text/html':
1178 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1182 body = tools.append_content_to_html(body, html, plaintext=False)
1183 # 4) Anything else -> attachment
1185 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1186 return body, attachments
1188 def message_parse(self, cr, uid, message, save_original=False, context=None):
1189 """Parses a string or email.message.Message representing an
1190 RFC-2822 email, and returns a generic dict holding the
1193 :param message: the message to parse
1194 :type message: email.message.Message | string | unicode
1195 :param bool save_original: whether the returned dict
1196 should include an ``original`` attachment containing
1197 the source of the message
1199 :return: A dict with the following structure, where each
1200 field may not be present if missing in original
1203 { 'message_id': msg_id,
1208 'body': unified_body,
1209 'attachments': [('file1', 'bytes'),
1216 if not isinstance(message, Message):
1217 if isinstance(message, unicode):
1218 # Warning: message_from_string doesn't always work correctly on unicode,
1219 # we must use utf-8 strings here :-(
1220 message = message.encode('utf-8')
1221 message = email.message_from_string(message)
1223 message_id = message['message-id']
1225 # Very unusual situation, be we should be fault-tolerant here
1226 message_id = "<%s@localhost>" % time.time()
1227 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1228 msg_dict['message_id'] = message_id
1230 if message.get('Subject'):
1231 msg_dict['subject'] = decode(message.get('Subject'))
1233 # Envelope fields not stored in mail.message but made available for message_new()
1234 msg_dict['from'] = decode(message.get('from'))
1235 msg_dict['to'] = decode(message.get('to'))
1236 msg_dict['cc'] = decode(message.get('cc'))
1237 msg_dict['email_from'] = decode(message.get('from'))
1238 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1239 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1241 if message.get('Date'):
1243 date_hdr = decode(message.get('Date'))
1244 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1245 if parsed_date.utcoffset() is None:
1246 # naive datetime, so we arbitrarily decide to make it
1247 # UTC, there's no better choice. Should not happen,
1248 # as RFC2822 requires timezone offset in Date headers.
1249 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1251 stored_date = parsed_date.astimezone(tz=pytz.utc)
1253 _logger.warning('Failed to parse Date header %r in incoming mail '
1254 'with message-id %r, assuming current date/time.',
1255 message.get('Date'), message_id)
1256 stored_date = datetime.datetime.now()
1257 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1259 if message.get('In-Reply-To'):
1260 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1262 msg_dict['parent_id'] = parent_ids[0]
1264 if message.get('References') and 'parent_id' not in msg_dict:
1265 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1266 [x.strip() for x in decode(message['References']).split()])])
1268 msg_dict['parent_id'] = parent_ids[0]
1270 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1273 #------------------------------------------------------
1275 #------------------------------------------------------
1277 def log(self, cr, uid, id, message, secondary=False, context=None):
1278 _logger.warning("log() is deprecated. As this module inherit from "\
1279 "mail.thread, the message will be managed by this "\
1280 "module instead of by the res.log mechanism. Please "\
1281 "use mail_thread.message_post() instead of the "\
1282 "now deprecated res.log.")
1283 self.message_post(cr, uid, [id], message, context=context)
1285 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1286 """ Called by message_get_suggested_recipients, to add a suggested
1287 recipient in the result dictionary. The form is :
1288 partner_id, partner_name<partner_email> or partner_name, reason """
1289 if email and not partner:
1290 # get partner info from email
1291 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1292 if partner_info.get('partner_id'):
1293 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1294 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1296 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1298 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1300 if partner and partner.email: # complete profile: id, name <email>
1301 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1302 elif partner: # incomplete profile: id, name
1303 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1304 else: # unknown partner, we are probably managing an email address
1305 result[obj.id].append((False, email, reason))
1308 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1309 """ Returns suggested recipients for ids. Those are a list of
1310 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1311 result = dict.fromkeys(ids, list())
1312 if self._all_columns.get('user_id'):
1313 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1314 if not obj.user_id or not obj.user_id.partner_id:
1316 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)
1319 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1320 """ Utility method to find partners from email addresses. The rules are :
1321 1 - check in document (model | self, id) followers
1322 2 - try to find a matching partner that is also an user
1323 3 - try to find a matching partner
1325 :param list emails: list of email addresses
1326 :param string model: model to fetch related record; by default self
1328 :param boolean check_followers: check in document followers
1330 partner_obj = self.pool['res.partner']
1333 if id and (model or self._name != 'mail.thread') and check_followers:
1335 obj = self.pool[model].browse(cr, uid, id, context=context)
1337 obj = self.browse(cr, uid, id, context=context)
1338 for contact in emails:
1340 email_address = tools.email_split(contact)
1341 if not email_address:
1342 partner_ids.append(partner_id)
1344 email_address = email_address[0]
1345 # first try: check in document's followers
1347 for follower in obj.message_follower_ids:
1348 if follower.email == email_address:
1349 partner_id = follower.id
1350 # second try: check in partners that are also users
1352 ids = partner_obj.search(cr, SUPERUSER_ID, [
1353 ('email', 'ilike', email_address),
1354 ('user_ids', '!=', False)
1355 ], limit=1, context=context)
1358 # third try: check in partners
1360 ids = partner_obj.search(cr, SUPERUSER_ID, [
1361 ('email', 'ilike', email_address)
1362 ], limit=1, context=context)
1365 partner_ids.append(partner_id)
1368 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1369 """ Convert a list of emails into a list partner_ids and a list
1370 new_partner_ids. The return value is non conventional because
1371 it is meant to be used by the mail widget.
1373 :return dict: partner_ids and new_partner_ids """
1374 mail_message_obj = self.pool.get('mail.message')
1375 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1377 for idx in range(len(emails)):
1378 email_address = emails[idx]
1379 partner_id = partner_ids[idx]
1380 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1381 result.append(partner_info)
1383 # link mail with this from mail to the new partner id
1384 if link_mail and partner_info['partner_id']:
1385 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1387 ('email_from', '=', email_address),
1388 ('email_from', 'ilike', '<%s>' % email_address),
1389 ('author_id', '=', False)
1392 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1395 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1396 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1398 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1399 where content is NOT base64 encoded
1400 :param list attachment_ids: a list of attachment ids, not in tomany command form
1401 :param str attach_model: the model of the attachments parent record
1402 :param integer attach_res_id: the id of the attachments parent record
1404 Attachment = self.pool['ir.attachment']
1405 m2m_attachment_ids = []
1407 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1408 ('res_model', '=', 'mail.compose.message'),
1409 ('create_uid', '=', uid),
1410 ('id', 'in', attachment_ids)], context=context)
1411 if filtered_attachment_ids:
1412 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1413 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1414 # Handle attachments parameter, that is a dictionary of attachments
1415 for name, content in attachments:
1416 if isinstance(content, unicode):
1417 content = content.encode('utf-8')
1420 'datas': base64.b64encode(str(content)),
1421 'datas_fname': name,
1422 'description': name,
1423 'res_model': attach_model,
1424 'res_id': attach_res_id,
1426 m2m_attachment_ids.append((0, 0, data_attach))
1427 return m2m_attachment_ids
1429 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1430 subtype=None, parent_id=False, attachments=None, context=None,
1431 content_subtype='html', **kwargs):
1432 """ Post a new message in an existing thread, returning the new
1435 :param int thread_id: thread ID to post into, or list with one ID;
1436 if False/0, mail.message model will also be set as False
1437 :param str body: body of the message, usually raw HTML that will
1439 :param str type: see mail_message.type field
1440 :param str content_subtype:: if plaintext: convert body into html
1441 :param int parent_id: handle reply to a previous message by adding the
1442 parent partners to the message in case of private discussion
1443 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1444 ``(name,content)``, where content is NOT base64 encoded
1446 Extra keyword arguments will be used as default column values for the
1447 new mail.message record. Special cases:
1448 - attachment_ids: supposed not attached to any document; attach them
1449 to the related document. Should only be set by Chatter.
1450 :return int: ID of newly created mail.message
1454 if attachments is None:
1456 mail_message = self.pool.get('mail.message')
1457 ir_attachment = self.pool.get('ir.attachment')
1459 assert (not thread_id) or \
1460 isinstance(thread_id, (int, long)) or \
1461 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1462 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1463 if isinstance(thread_id, (list, tuple)):
1464 thread_id = thread_id[0]
1466 # if we're processing a message directly coming from the gateway, the destination model was
1467 # set in the context.
1470 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1471 if model != self._name and hasattr(self.pool[model], 'message_post'):
1472 del context['thread_model']
1473 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)
1475 #0: Find the message's author, because we need it for private discussion
1476 author_id = kwargs.get('author_id')
1477 if author_id is None: # keep False values
1478 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1480 # 1: Handle content subtype: if plaintext, converto into HTML
1481 if content_subtype == 'plaintext':
1482 body = tools.plaintext2html(body)
1484 # 2: Private message: add recipients (recipients and author of parent message) - current author
1485 # + legacy-code management (! we manage only 4 and 6 commands)
1487 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1488 for partner_id in kwargs_partner_ids:
1489 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1490 partner_ids.add(partner_id[1])
1491 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1492 partner_ids |= set(partner_id[2])
1493 elif isinstance(partner_id, (int, long)):
1494 partner_ids.add(partner_id)
1496 pass # we do not manage anything else
1497 if parent_id and not model:
1498 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1499 private_followers = set([partner.id for partner in parent_message.partner_ids])
1500 if parent_message.author_id:
1501 private_followers.add(parent_message.author_id.id)
1502 private_followers -= set([author_id])
1503 partner_ids |= private_followers
1506 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1507 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1509 # 4: mail.message.subtype
1512 if '.' not in subtype:
1513 subtype = 'mail.%s' % subtype
1514 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1515 subtype_id = ref and ref[1] or False
1517 # automatically subscribe recipients if asked to
1518 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1519 partner_to_subscribe = partner_ids
1520 if context.get('mail_post_autofollow_partner_ids'):
1521 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1522 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1524 # _mail_flat_thread: automatically set free messages to the first posted message
1525 if self._mail_flat_thread and not parent_id and thread_id:
1526 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1527 parent_id = message_ids and message_ids[0] or False
1528 # 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
1530 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1531 # avoid loops when finding ancestors
1534 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1535 while (message.parent_id and message.parent_id.id not in processed_list):
1536 processed_list.append(message.parent_id.id)
1537 message = message.parent_id
1538 parent_id = message.id
1542 'author_id': author_id,
1544 'res_id': thread_id or False,
1546 'subject': subject or False,
1548 'parent_id': parent_id,
1549 'attachment_ids': attachment_ids,
1550 'subtype_id': subtype_id,
1551 'partner_ids': [(4, pid) for pid in partner_ids],
1554 # Avoid warnings about non-existing fields
1555 for x in ('from', 'to', 'cc'):
1559 msg_id = mail_message.create(cr, uid, values, context=context)
1561 # Post-process: subscribe author, update message_last_post
1562 if model and model != 'mail.thread' and thread_id and subtype_id:
1563 # done with SUPERUSER_ID, because on some models users can post only with read access, not necessarily write access
1564 self.write(cr, SUPERUSER_ID, [thread_id], {'message_last_post': fields.datetime.now()}, context=context)
1565 message = mail_message.browse(cr, uid, msg_id, context=context)
1566 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1567 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1570 #------------------------------------------------------
1572 #------------------------------------------------------
1574 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1575 """ Wrapper to get subtypes data. """
1576 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1578 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1579 """ Wrapper on message_subscribe, using users. If user_ids is not
1580 provided, subscribe uid instead. """
1581 if user_ids is None:
1583 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1584 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1586 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1587 """ Add partners to the records followers. """
1590 # not necessary for computation, but saves an access right check
1594 mail_followers_obj = self.pool.get('mail.followers')
1595 subtype_obj = self.pool.get('mail.message.subtype')
1597 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1598 if set(partner_ids) == set([user_pid]):
1600 self.check_access_rights(cr, uid, 'read')
1601 self.check_access_rule(cr, uid, ids, 'read')
1602 except (osv.except_osv, orm.except_orm):
1605 self.check_access_rights(cr, uid, 'write')
1606 self.check_access_rule(cr, uid, ids, 'write')
1608 existing_pids_dict = {}
1609 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1610 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1611 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1613 # subtype_ids specified: update already subscribed partners
1614 if subtype_ids and fol_ids:
1615 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1616 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1617 if subtype_ids is None:
1618 subtype_ids = subtype_obj.search(
1620 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1623 existing_pids = existing_pids_dict.get(id, set())
1624 new_pids = set(partner_ids) - existing_pids
1626 # subscribe new followers
1627 for new_pid in new_pids:
1628 mail_followers_obj.create(
1630 'res_model': self._name,
1632 'partner_id': new_pid,
1633 'subtype_ids': [(6, 0, subtype_ids)],
1638 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1639 """ Wrapper on message_subscribe, using users. If user_ids is not
1640 provided, unsubscribe uid instead. """
1641 if user_ids is None:
1643 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1644 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1646 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1647 """ Remove partners from the records followers. """
1648 # not necessary for computation, but saves an access right check
1651 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1652 if set(partner_ids) == set([user_pid]):
1653 self.check_access_rights(cr, uid, 'read')
1654 self.check_access_rule(cr, uid, ids, 'read')
1656 self.check_access_rights(cr, uid, 'write')
1657 self.check_access_rule(cr, uid, ids, 'write')
1658 fol_obj = self.pool['mail.followers']
1659 fol_ids = fol_obj.search(
1661 ('res_model', '=', self._name),
1662 ('res_id', 'in', ids),
1663 ('partner_id', 'in', partner_ids)
1665 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1667 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1668 """ Returns the list of relational fields linking to res.users that should
1669 trigger an auto subscribe. The default list checks for the fields
1671 - linking to res.users
1672 - with track_visibility set
1673 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1674 project, issue, recruitment, sale.
1675 Override this method if a custom behavior is needed about fields
1676 that automatically subscribe users.
1679 for name, column_info in self._all_columns.items():
1680 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':
1681 user_field_lst.append(name)
1682 return user_field_lst
1684 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1685 """ Handle auto subscription. Two methods for auto subscription exist:
1687 - tracked res.users relational fields, such as user_id fields. Those fields
1688 must be relation fields toward a res.users record, and must have the
1689 track_visilibity attribute set.
1690 - using subtypes parent relationship: check if the current model being
1691 modified has an header record (such as a project for tasks) whose followers
1692 can be added as followers of the current records. Example of structure
1693 with project and task:
1695 - st_project_1.parent_id = st_task_1
1696 - st_project_1.res_model = 'project.project'
1697 - st_project_1.relation_field = 'project_id'
1698 - st_task_1.model = 'project.task'
1700 :param list updated_fields: list of updated fields to track
1701 :param dict values: updated values; if None, the first record will be browsed
1702 to get the values. Added after releasing 7.0, therefore
1703 not merged with updated_fields argumment.
1705 subtype_obj = self.pool.get('mail.message.subtype')
1706 follower_obj = self.pool.get('mail.followers')
1707 new_followers = dict()
1709 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1710 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1712 # fetch header subtypes
1713 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1714 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1716 # if no change in tracked field or no change in tracked relational field: quit
1717 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1718 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1721 # legacy behavior: if values is not given, compute the values by browsing
1722 # @TDENOTE: remove me in 8.0
1724 record = self.browse(cr, uid, ids[0], context=context)
1725 for updated_field in updated_fields:
1726 field_value = getattr(record, updated_field)
1727 if isinstance(field_value, browse_record):
1728 field_value = field_value.id
1729 elif isinstance(field_value, browse_null):
1731 values[updated_field] = field_value
1733 # find followers of headers, update structure for new followers
1735 for subtype in subtypes:
1736 if subtype.relation_field and values.get(subtype.relation_field):
1737 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1739 header_domain = ['|'] * (len(headers) - 1)
1740 for header in headers:
1741 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1742 header_follower_ids = follower_obj.search(
1747 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1748 for subtype in header_follower.subtype_ids:
1749 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1750 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1751 elif subtype.res_model is False:
1752 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1754 # add followers coming from res.users relational fields that are tracked
1755 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1756 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1757 for partner_id in user_pids:
1758 new_followers.setdefault(partner_id, None)
1760 for pid, subtypes in new_followers.items():
1761 subtypes = list(subtypes) if subtypes is not None else None
1762 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1764 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1766 for record_id in ids:
1767 message_obj = self.pool.get('mail.message')
1768 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1769 ('model', '=', self._name),
1770 ('res_id', '=', record_id),
1771 ('type', '=', 'email')], limit=1, context=context)
1773 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1774 ('model', '=', self._name),
1775 ('res_id', '=', record_id)], limit=1, context=context)
1777 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1781 #------------------------------------------------------
1783 #------------------------------------------------------
1785 def message_mark_as_unread(self, cr, uid, ids, context=None):
1786 """ Set as unread. """
1787 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1789 UPDATE mail_notification SET
1792 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1794 ''', (ids, self._name, partner_id))
1797 def message_mark_as_read(self, cr, uid, ids, context=None):
1798 """ Set as read. """
1799 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1801 UPDATE mail_notification SET
1804 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1806 ''', (ids, self._name, partner_id))
1809 #------------------------------------------------------
1811 #------------------------------------------------------
1813 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1814 """Return a list of suggested threads, sorted by the numbers of followers"""
1818 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1819 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1820 if self.pool['res.groups']._all_columns.get('is_portal'):
1821 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1822 if any(group.is_portal for group in user.groups_id):
1826 if removed_suggested_threads is None:
1827 removed_suggested_threads = []
1829 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1830 for thread in self.browse(cr, uid, thread_ids, context=context):
1833 'popularity': len(thread.message_follower_ids),
1834 'name': thread.name,
1835 'image_small': thread.image_small
1837 threads.append(data)
1838 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]
1840 def message_change_thread(self, cr, uid, id, new_res_id, new_model, context=None):
1842 Transfert the list of the mail thread messages from an model to another
1844 :param id : the old res_id of the mail.message
1845 :param new_res_id : the new res_id of the mail.message
1846 :param new_model : the name of the new model of the mail.message
1848 Example : self.pool.get("crm.lead").message_change_thread(self, cr, uid, 2, 4, "project.issue", context)
1849 will transfert thread of the lead (id=2) to the issue (id=4)
1852 # get the sbtype id of the comment Message
1853 subtype_res_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, 'mail.mt_comment', raise_if_not_found=True)
1855 # get the ids of the comment and none-comment of the thread
1856 message_obj = self.pool.get('mail.message')
1857 msg_ids_comment = message_obj.search(cr, uid, [
1858 ('model', '=', self._name),
1859 ('res_id', '=', id),
1860 ('subtype_id', '=', subtype_res_id)], context=context)
1861 msg_ids_not_comment = message_obj.search(cr, uid, [
1862 ('model', '=', self._name),
1863 ('res_id', '=', id),
1864 ('subtype_id', '!=', subtype_res_id)], context=context)
1866 # update the messages
1867 message_obj.write(cr, uid, msg_ids_comment, {"res_id" : new_res_id, "model" : new_model}, context=context)
1868 message_obj.write(cr, uid, msg_ids_not_comment, {"res_id" : new_res_id, "model" : new_model, "subtype_id" : None}, context=context)