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
37 from email.message import Message
38 from urllib import urlencode
40 from openerp import tools
41 from openerp import SUPERUSER_ID
42 from openerp.addons.mail.mail_message import decode
43 from openerp.osv import fields, osv, orm
44 from openerp.osv.orm import browse_record, browse_null
45 from openerp.tools.safe_eval import safe_eval as eval
46 from openerp.tools.translate import _
48 _logger = logging.getLogger(__name__)
51 def decode_header(message, header, separator=' '):
52 return separator.join(map(decode, filter(None, message.get_all(header, []))))
55 class mail_thread(osv.AbstractModel):
56 ''' mail_thread model is meant to be inherited by any model that needs to
57 act as a discussion topic on which messages can be attached. Public
58 methods are prefixed with ``message_`` in order to avoid name
59 collisions with methods of the models that will inherit from this class.
61 ``mail.thread`` defines fields used to handle and display the
62 communication history. ``mail.thread`` also manages followers of
63 inheriting classes. All features and expected behavior are managed
64 by mail.thread. Widgets has been designed for the 7.0 and following
67 Inheriting classes are not required to implement any method, as the
68 default implementation will work for any model. However it is common
69 to override at least the ``message_new`` and ``message_update``
70 methods (calling ``super``) to add model-specific behavior at
71 creation and update of a thread when processing incoming emails.
74 - _mail_flat_thread: if set to True, all messages without parent_id
75 are automatically attached to the first message posted on the
76 ressource. If set to False, the display of Chatter is done using
77 threads, and no parent_id is automatically set.
80 _description = 'Email Thread'
81 _mail_flat_thread = True
82 _mail_post_access = 'write'
84 # Automatic logging system if mail installed
87 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
88 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
95 # :param string field: field name
96 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
97 # :param obj: is a browse_record
98 # :param function lambda: returns whether the tracking should record using this subtype
101 # Mass mailing feature
102 _mail_mass_mailing = False
104 def get_empty_list_help(self, cr, uid, help, context=None):
105 """ Override of BaseModel.get_empty_list_help() to generate an help message
106 that adds alias information. """
107 model = context.get('empty_list_help_model')
108 res_id = context.get('empty_list_help_id')
109 ir_config_parameter = self.pool.get("ir.config_parameter")
110 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
111 document_name = context.get('empty_list_help_document_name', _('document'))
114 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
115 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
116 # check that the alias effectively creates new records
117 if object_id.alias_id and object_id.alias_id.alias_name and \
118 object_id.alias_id.alias_model_id and \
119 object_id.alias_id.alias_model_id.model == self._name and \
120 object_id.alias_id.alias_force_thread_id == 0:
121 alias = object_id.alias_id
122 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
123 alias_obj = self.pool.get('mail.alias')
124 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')
125 if alias_ids and len(alias_ids) == 1:
126 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
129 alias_email = alias.name_get()[0][1]
130 return _("""<p class='oe_view_nocontent_create'>
131 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
135 'document': document_name,
136 'email': alias_email,
137 'static_help': help or ''
140 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
141 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
142 'document': document_name,
143 'static_help': help or '',
148 def _get_message_data(self, cr, uid, ids, name, args, context=None):
150 - message_unread: has uid unread message for the document
151 - message_summary: html snippet summarizing the Chatter for kanban views """
152 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
153 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
155 # search for unread messages, directly in SQL to improve performances
156 cr.execute(""" SELECT m.res_id FROM mail_message m
157 RIGHT JOIN mail_notification n
158 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
159 WHERE m.model = %s AND m.res_id in %s""",
160 (user_pid, self._name, tuple(ids),))
161 for result in cr.fetchall():
162 res[result[0]]['message_unread'] = True
163 res[result[0]]['message_unread_count'] += 1
166 if res[id]['message_unread_count']:
167 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
168 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"))
169 res[id].pop('message_unread_count', None)
172 def read_followers_data(self, cr, uid, follower_ids, context=None):
174 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
175 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
176 is_editable = uid in map(lambda x: x.id, technical_group.users)
177 is_uid = uid in map(lambda x: x.id, follower.user_ids)
180 {'is_editable': is_editable, 'is_uid': is_uid},
185 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
187 - message_subtype_data: data about document subtypes: which are
188 available, which are followed if any """
189 res = dict((id, dict(message_subtype_data='')) for id in ids)
191 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
193 # find current model subtypes, add them to a dictionary
194 subtype_obj = self.pool.get('mail.message.subtype')
195 subtype_ids = subtype_obj.search(
197 '&', ('hidden', '=', False), '|', ('res_model', '=', self._name), ('res_model', '=', False)
199 subtype_dict = OrderedDict(
201 'default': subtype.default,
203 'parent_model': subtype.parent_id and subtype.parent_id.res_model or self._name,
205 ) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
207 res[id]['message_subtype_data'] = subtype_dict.copy()
209 # find the document followers, update the data
210 fol_obj = self.pool.get('mail.followers')
211 fol_ids = fol_obj.search(cr, uid, [
212 ('partner_id', '=', user_pid),
213 ('res_id', 'in', ids),
214 ('res_model', '=', self._name),
216 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
217 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
218 for subtype in [st for st in fol.subtype_ids if st.name in thread_subtype_dict]:
219 thread_subtype_dict[subtype.name]['followed'] = True
220 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
224 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
225 return [('message_ids.to_read', '=', True)]
227 def _get_followers(self, cr, uid, ids, name, arg, context=None):
228 fol_obj = self.pool.get('mail.followers')
229 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
230 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
231 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
232 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
233 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
234 if fol.partner_id.id == user_pid:
235 res[fol.res_id]['message_is_follower'] = True
238 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
241 partner_obj = self.pool.get('res.partner')
242 fol_obj = self.pool.get('mail.followers')
244 # read the old set of followers, and determine the new set of followers
245 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
246 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
249 for command in value or []:
250 if isinstance(command, (int, long)):
252 elif command[0] == 0:
253 new.add(partner_obj.create(cr, uid, command[2], context=context))
254 elif command[0] == 1:
255 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
257 elif command[0] == 2:
258 partner_obj.unlink(cr, uid, [command[1]], context=context)
259 new.discard(command[1])
260 elif command[0] == 3:
261 new.discard(command[1])
262 elif command[0] == 4:
264 elif command[0] == 5:
266 elif command[0] == 6:
267 new = set(command[2])
269 # remove partners that are no longer followers
270 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
272 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
274 def _search_followers(self, cr, uid, obj, name, args, context):
275 """Search function for message_follower_ids
277 Do not use with operator 'not in'. Use instead message_is_followers
279 fol_obj = self.pool.get('mail.followers')
281 for field, operator, value in args:
283 # TOFIX make it work with not in
284 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
285 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
286 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
287 res.append(('id', 'in', res_ids))
290 def _search_is_follower(self, cr, uid, obj, name, args, context):
291 """Search function for message_is_follower"""
293 for field, operator, value in args:
295 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
296 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
297 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
298 else: # is not a follower or unknown domain
299 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
300 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
301 res.append(('id', 'in', res_ids))
305 'message_is_follower': fields.function(_get_followers, type='boolean',
306 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
307 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
308 fnct_search=_search_followers, type='many2many', priority=-10,
309 obj='res.partner', string='Followers', multi='_get_followers'),
310 'message_ids': fields.one2many('mail.message', 'res_id',
311 domain=lambda self: [('model', '=', self._name)],
314 help="Messages and communication history"),
315 'message_last_post': fields.datetime('Last Message Date',
316 help='Date of the last message posted on the record.'),
317 'message_unread': fields.function(_get_message_data,
318 fnct_search=_search_message_unread, multi="_get_message_data",
319 type='boolean', string='Unread Messages',
320 help="If checked new messages require your attention."),
321 'message_summary': fields.function(_get_message_data, method=True,
322 type='text', string='Summary', multi="_get_message_data",
323 help="Holds the Chatter summary (number of messages, ...). "\
324 "This summary is directly in html format in order to "\
325 "be inserted in kanban views."),
328 def _get_user_chatter_options(self, cr, uid, context=None):
330 'display_log_button': False
332 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
333 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
334 is_employee = group_user_id in [group.id for group in group_ids]
336 options['display_log_button'] = True
339 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
340 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
341 if view_type == 'form':
342 doc = etree.XML(res['arch'])
343 for node in doc.xpath("//field[@name='message_ids']"):
344 options = json.loads(node.get('options', '{}'))
345 options.update(self._get_user_chatter_options(cr, uid, context=context))
346 node.set('options', json.dumps(options))
347 res['arch'] = etree.tostring(doc)
350 #------------------------------------------------------
351 # CRUD overrides for automatic subscription and logging
352 #------------------------------------------------------
354 def create(self, cr, uid, values, context=None):
355 """ Chatter override :
357 - subscribe followers of parent
358 - log a creation message
363 # subscribe uid unless asked not to
364 if not context.get('mail_create_nosubscribe'):
365 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
366 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
367 message_follower_ids.append([4, pid])
368 values['message_follower_ids'] = message_follower_ids
369 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
371 # automatic logging unless asked not to (mainly for various testing purpose)
372 if not context.get('mail_create_nolog'):
373 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
375 # auto_subscribe: take values and defaults into account
376 create_values = dict(values)
377 for key, val in context.iteritems():
378 if key.startswith('default_'):
379 create_values[key[8:]] = val
380 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
383 track_ctx = dict(context)
384 if 'lang' not in track_ctx:
385 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
386 if not context.get('mail_notrack'):
387 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
389 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
390 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
393 def write(self, cr, uid, ids, values, context=None):
396 if isinstance(ids, (int, long)):
398 # Track initial values of tracked fields
399 track_ctx = dict(context)
400 if 'lang' not in track_ctx:
401 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
402 if not context.get('mail_notrack'):
403 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
407 records = self.browse(cr, uid, ids, context=track_ctx)
408 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
410 # Perform write, update followers
411 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
412 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
414 if not context.get('mail_notrack'):
415 # Perform the tracking
416 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
418 tracked_fields = None
420 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
423 def unlink(self, cr, uid, ids, context=None):
424 """ Override unlink to delete messages and followers. This cannot be
425 cascaded, because link is done through (res_model, res_id). """
426 msg_obj = self.pool.get('mail.message')
427 fol_obj = self.pool.get('mail.followers')
428 # delete messages and notifications
429 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
430 msg_obj.unlink(cr, uid, msg_ids, context=context)
432 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
434 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
435 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
438 def copy_data(self, cr, uid, id, default=None, context=None):
439 # avoid tracking multiple temporary changes during copy
440 context = dict(context or {}, mail_notrack=True)
442 default = default or {}
443 default['message_ids'] = []
444 default['message_follower_ids'] = []
445 return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
447 #------------------------------------------------------
448 # Automatically log tracked fields
449 #------------------------------------------------------
451 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
452 """ Return a structure of tracked fields for the current model.
453 :param list updated_fields: modified field names
454 :return list: a list of (field_name, column_info obj), containing
455 always tracked fields and modified on_change fields
458 for name, column_info in self._all_columns.items():
459 visibility = getattr(column_info.column, 'track_visibility', False)
460 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
464 return self.fields_get(cr, uid, lst, context=context)
466 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
468 def convert_for_display(value, col_info):
469 if not value and col_info['type'] == 'boolean':
473 if col_info['type'] == 'many2one':
474 return value.name_get()[0][1]
475 if col_info['type'] == 'selection':
476 return dict(col_info['selection'])[value]
479 def format_message(message_description, tracked_values):
481 if message_description:
482 message = '<span>%s</span>' % message_description
483 for name, change in tracked_values.items():
484 message += '<div> • <b>%s</b>: ' % change.get('col_info')
485 if change.get('old_value'):
486 message += '%s → ' % change.get('old_value')
487 message += '%s</div>' % change.get('new_value')
490 if not tracked_fields:
493 for browse_record in self.browse(cr, uid, ids, context=context):
494 initial = initial_values[browse_record.id]
498 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
499 for col_name, col_info in tracked_fields.items():
500 initial_value = initial[col_name]
501 record_value = getattr(browse_record, col_name)
503 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
504 tracked_values[col_name] = dict(col_info=col_info['string'],
505 new_value=convert_for_display(record_value, col_info))
506 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
507 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
508 tracked_values[col_name] = dict(col_info=col_info['string'],
509 old_value=convert_for_display(initial_value, col_info),
510 new_value=convert_for_display(record_value, col_info))
511 if col_name in tracked_fields:
512 changes.add(col_name)
516 # find subtypes and post messages or log if no subtype found
518 for field, track_info in self._track.items():
519 if field not in changes:
521 for subtype, method in track_info.items():
522 if method(self, cr, uid, browse_record, context):
523 subtypes.append(subtype)
526 for subtype in subtypes:
527 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
528 if not (subtype_rec and subtype_rec.exists()):
529 _logger.debug('subtype %s not found' % subtype)
531 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
532 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
535 message = format_message('', tracked_values)
536 self.message_post(cr, uid, browse_record.id, body=message, context=context)
539 #------------------------------------------------------
540 # mail.message wrappers and tools
541 #------------------------------------------------------
543 def _needaction_domain_get(self, cr, uid, context=None):
545 return [('message_unread', '=', True)]
548 def _garbage_collect_attachments(self, cr, uid, context=None):
549 """ Garbage collect lost mail attachments. Those are attachments
550 - linked to res_model 'mail.compose.message', the composer wizard
551 - with res_id 0, because they were created outside of an existing
552 wizard (typically user input through Chatter or reports
553 created on-the-fly by the templates)
554 - unused since at least one day (create_date and write_date)
556 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
557 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
558 ir_attachment_obj = self.pool.get('ir.attachment')
559 attach_ids = ir_attachment_obj.search(cr, uid, [
560 ('res_model', '=', 'mail.compose.message'),
562 ('create_date', '<', limit_date_str),
563 ('write_date', '<', limit_date_str),
565 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
568 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
569 """ mail.message check permission rules for related document. This method is
570 meant to be inherited in order to implement addons-specific behavior.
571 A common behavior would be to allow creating messages when having read
572 access rule on the document, for portal document such as issues. """
575 if hasattr(self, '_mail_post_access'):
576 create_allow = self._mail_post_access
578 create_allow = 'write'
580 if operation in ['write', 'unlink']:
581 check_operation = 'write'
582 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
583 check_operation = create_allow
584 elif operation == 'create':
585 check_operation = 'write'
587 check_operation = operation
589 model_obj.check_access_rights(cr, uid, check_operation)
590 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
592 def _get_inbox_action_xml_id(self, cr, uid, context=None):
593 """ When redirecting towards the Inbox, choose which action xml_id has
594 to be fetched. This method is meant to be inherited, at least in portal
595 because portal users have a different Inbox action than classic users. """
596 return ('mail', 'action_mail_inbox_feeds')
598 def message_redirect_action(self, cr, uid, context=None):
599 """ For a given message, return an action that either
600 - opens the form view of the related document if model, res_id, and
601 read access to the document
602 - opens the Inbox with a default search on the conversation if model,
604 - opens the Inbox with context propagated
610 # default action is the Inbox action
611 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
612 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))
613 action = self.pool.get(act_model).read(cr, uid, act_id, [])
614 params = context.get('params')
615 msg_id = model = res_id = None
618 msg_id = params.get('message_id')
619 model = params.get('model')
620 res_id = params.get('res_id')
621 if not msg_id and not (model and res_id):
623 if msg_id and not (model and res_id):
624 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
626 model, res_id = msg.model, msg.res_id
628 # if model + res_id found: try to redirect to the document or fallback on the Inbox
630 model_obj = self.pool.get(model)
631 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
633 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
634 action = model_obj.get_formview_action(cr, uid, res_id, context=context)
635 except (osv.except_osv, orm.except_orm):
639 'search_default_model': model,
640 'search_default_res_id': res_id,
645 def _get_access_link(self, cr, uid, mail, partner, context=None):
646 # the parameters to encode for the query and fragment part of url
647 query = {'db': cr.dbname}
649 'login': partner.user_ids[0].login,
650 'action': 'mail.action_mail_redirect',
652 if mail.notification:
653 fragment['message_id'] = mail.mail_message_id.id
654 elif mail.model and mail.res_id:
655 fragment.update(model=mail.model, res_id=mail.res_id)
657 return "/web?%s#%s" % (urlencode(query), urlencode(fragment))
659 #------------------------------------------------------
661 #------------------------------------------------------
663 def message_get_default_recipients(self, cr, uid, ids, context=None):
664 if context and context.get('thread_model') and context['thread_model'] in self.pool and context['thread_model'] != self._name:
665 if hasattr(self.pool[context['thread_model']], 'message_get_default_recipients'):
666 sub_ctx = dict(context)
667 sub_ctx.pop('thread_model')
668 return self.pool[context['thread_model']].message_get_default_recipients(cr, uid, ids, context=sub_ctx)
670 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
671 recipient_ids, email_to, email_cc = set(), False, False
672 if 'partner_id' in self._all_columns and record.partner_id:
673 recipient_ids.add(record.partner_id.id)
674 elif 'email_from' in self._all_columns and record.email_from:
675 email_to = record.email_from
676 elif 'email' in self._all_columns:
677 email_to = record.email
678 res[record.id] = {'partner_ids': list(recipient_ids), 'email_to': email_to, 'email_cc': email_cc}
681 def message_get_reply_to(self, cr, uid, ids, context=None):
682 """ Returns the preferred reply-to email address that is basically
683 the alias of the document, if it exists. """
684 if not self._inherits.get('mail.alias'):
685 return [False for id in ids]
686 return ["%s@%s" % (record.alias_name, record.alias_domain)
687 if record.alias_domain and record.alias_name else False
688 for record in self.browse(cr, SUPERUSER_ID, ids, context=context)]
690 #------------------------------------------------------
692 #------------------------------------------------------
694 def message_capable_models(self, cr, uid, context=None):
695 """ Used by the plugin addon, based for plugin_outlook and others. """
697 for model_name in self.pool.obj_list():
698 model = self.pool[model_name]
699 if hasattr(model, "message_process") and hasattr(model, "message_post"):
700 ret_dict[model_name] = model._description
703 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
704 """ Find partners related to some header fields of the message.
706 :param string message: an email.message instance """
707 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
708 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
710 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
711 """ Verify route validity. Check and rules:
712 1 - if thread_id -> check that document effectively exists; otherwise
713 fallback on a message_new by resetting thread_id
714 2 - check that message_update exists if thread_id is set; or at least
715 that message_new exist
716 [ - find author_id if udpate_author is set]
717 3 - if there is an alias, check alias_contact:
718 'followers' and thread_id:
719 check on target document that the author is in the followers
720 'followers' and alias_parent_thread_id:
721 check on alias parent document that the author is in the
723 'partners': check that author_id id set
726 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
727 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
729 message_id = message.get('Message-Id')
730 email_from = decode_header(message, 'From')
731 author_id = message_dict.get('author_id')
732 model, thread_id, alias = route[0], route[1], route[4]
735 def _create_bounce_email():
736 mail_mail = self.pool.get('mail.mail')
737 mail_id = mail_mail.create(cr, uid, {
738 'body_html': '<div><p>Hello,</p>'
739 '<p>The following email sent to %s cannot be accepted because this is '
740 'a private email address. Only allowed people can contact us at this address.</p></div>'
741 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
742 'subject': 'Re: %s' % message.get('subject'),
743 'email_to': message.get('from'),
746 mail_mail.send(cr, uid, [mail_id], context=context)
749 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
750 message_id, route, message)
753 if model and not model in self.pool:
755 assert model in self.pool, 'Routing: unknown target model %s' % model
756 _warn('unknown target model %s' % model)
759 model_pool = self.pool[model]
761 # Private message: should not contain any thread_id
762 if not model and thread_id:
765 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
766 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
768 # Private message: should have a parent_id (only answers)
769 if not model and not message_dict.get('parent_id'):
771 if not message_dict.get('parent_id'):
772 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
773 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
776 # Existing Document: check if exists; if not, fallback on create if allowed
777 if thread_id and not model_pool.exists(cr, uid, thread_id):
779 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
782 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
784 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
787 # Existing Document: check model accepts the mailgateway
788 if thread_id and model and not hasattr(model_pool, 'message_update'):
790 _warn('model %s does not accept document update, fall back on document creation' % model)
793 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
795 _warn('model %s does not accept document update, skipping' % model)
798 # New Document: check model accepts the mailgateway
799 if not thread_id and model and not hasattr(model_pool, 'message_new'):
801 if not hasattr(model_pool, 'message_new'):
803 'Model %s does not accept document creation, crashing' % model
805 _warn('model %s does not accept document creation, skipping' % model)
808 # Update message author if asked
809 # We do it now because we need it for aliases (contact settings)
810 if not author_id and update_author:
811 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
813 author_id = author_ids[0]
814 message_dict['author_id'] = author_id
816 # Alias: check alias_contact settings
817 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
819 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
821 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
822 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
823 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
824 _create_bounce_email()
826 elif alias and alias.alias_contact == 'partners' and not author_id:
827 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
828 _create_bounce_email()
831 return (model, thread_id, route[2], route[3], route[4])
833 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
834 custom_values=None, context=None):
835 """Attempt to figure out the correct target model, thread_id,
836 custom_values and user_id to use for an incoming message.
837 Multiple values may be returned, if a message had multiple
838 recipients matching existing mail.aliases, for example.
840 The following heuristics are used, in this order:
841 1. If the message replies to an existing thread_id, and
842 properly contains the thread model in the 'In-Reply-To'
843 header, use this model/thread_id pair, and ignore
844 custom_value (not needed as no creation will take place)
845 2. Look for a mail.alias entry matching the message
846 recipient, and use the corresponding model, thread_id,
847 custom_values and user_id.
848 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
850 4. If all the above fails, raise an exception.
852 :param string message: an email.message instance
853 :param dict message_dict: dictionary holding message variables
854 :param string model: the fallback model to use if the message
855 does not match any of the currently configured mail aliases
856 (may be None if a matching alias is supposed to be present)
857 :type dict custom_values: optional dictionary of default field values
858 to pass to ``message_new`` if a new record needs to be created.
859 Ignored if the thread record already exists, and also if a
860 matching mail.alias was found (aliases define their own defaults)
861 :param int thread_id: optional ID of the record/thread from ``model``
862 to which this mail should be attached. Only used if the message
863 does not reply to an existing thread and does not match any mail alias.
864 :return: list of [model, thread_id, custom_values, user_id, alias]
866 :raises: ValueError, TypeError
868 if not isinstance(message, Message):
869 raise TypeError('message must be an email.message.Message at this point')
870 mail_msg_obj = self.pool['mail.message']
871 fallback_model = model
873 # Get email.message.Message variables for future processing
874 message_id = message.get('Message-Id')
875 email_from = decode_header(message, 'From')
876 email_to = decode_header(message, 'To')
877 references = decode_header(message, 'References')
878 in_reply_to = decode_header(message, 'In-Reply-To')
879 thread_references = references or in_reply_to
881 # 1. message is a reply to an existing message (exact match of message_id)
882 ref_match = thread_references and tools.reference_re.search(thread_references)
883 msg_references = thread_references.split()
884 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
885 if ref_match and mail_message_ids:
886 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
887 model, thread_id = original_msg.model, original_msg.res_id
888 route = self.message_route_verify(
889 cr, uid, message, message_dict,
890 (model, thread_id, custom_values, uid, None),
891 update_author=True, assert_model=False, create_fallback=True, context=context)
894 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
895 email_from, email_to, message_id, model, thread_id, custom_values, uid)
898 # 2. message is a reply to an existign thread (6.1 compatibility)
900 reply_thread_id = int(ref_match.group(1))
901 reply_model = ref_match.group(2) or fallback_model
902 reply_hostname = ref_match.group(3)
903 local_hostname = socket.gethostname()
904 # do not match forwarded emails from another OpenERP system (thread_id collision!)
905 if local_hostname == reply_hostname:
906 thread_id, model = reply_thread_id, reply_model
907 if thread_id and model in self.pool:
908 model_obj = self.pool[model]
909 compat_mail_msg_ids = mail_msg_obj.search(
911 ('message_id', '=', False),
912 ('model', '=', model),
913 ('res_id', '=', thread_id),
915 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
916 route = self.message_route_verify(
917 cr, uid, message, message_dict,
918 (model, thread_id, custom_values, uid, None),
919 update_author=True, assert_model=True, create_fallback=True, context=context)
922 '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',
923 email_from, email_to, message_id, model, thread_id, custom_values, uid)
926 # 2. Reply to a private message
928 mail_message_ids = mail_msg_obj.search(cr, uid, [
929 ('message_id', '=', in_reply_to),
930 '!', ('message_id', 'ilike', 'reply_to')
931 ], limit=1, context=context)
933 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
934 route = self.message_route_verify(cr, uid, message, message_dict,
935 (mail_message.model, mail_message.res_id, custom_values, uid, None),
936 update_author=True, assert_model=True, create_fallback=True, context=context)
939 'Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
940 email_from, email_to, message_id, mail_message.id, custom_values, uid)
943 # 3. Look for a matching mail.alias entry
944 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
945 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
947 ','.join([decode_header(message, 'Delivered-To'),
948 decode_header(message, 'To'),
949 decode_header(message, 'Cc'),
950 decode_header(message, 'Resent-To'),
951 decode_header(message, 'Resent-Cc')])
952 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
954 mail_alias = self.pool.get('mail.alias')
955 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
958 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
959 user_id = alias.alias_user_id.id
961 # TDE note: this could cause crashes, because no clue that the user
962 # that send the email has the right to create or modify a new document
963 # Fallback on user_id = uid
964 # Note: recognized partners will be added as followers anyway
965 # user_id = self._message_find_user_id(cr, uid, message, context=context)
967 _logger.info('No matching user_id for the alias %s', alias.alias_name)
968 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
969 route = self.message_route_verify(cr, uid, message, message_dict, route,
970 update_author=True, assert_model=True, create_fallback=True, context=context)
973 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
974 email_from, email_to, message_id, route)
978 # 4. Fallback to the provided parameters, if they work
980 # Legacy: fallback to matching [ID] in the Subject
981 match = tools.res_re.search(decode_header(message, 'Subject'))
982 thread_id = match and match.group(1)
983 # Convert into int (bug spotted in 7.0 because of str)
985 thread_id = int(thread_id)
988 route = self.message_route_verify(cr, uid, message, message_dict,
989 (fallback_model, thread_id, custom_values, uid, None),
990 update_author=True, assert_model=True, context=context)
993 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
994 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
997 # ValueError if no routes found and if no bounce occured
999 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
1000 'Create an appropriate mail.alias or force the destination model.' %
1001 (email_from, email_to, message_id)
1004 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
1005 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
1006 partner_ids = message_dict.pop('partner_ids', [])
1008 for model, thread_id, custom_values, user_id, alias in routes:
1009 if self._name == 'mail.thread':
1010 context.update({'thread_model': model})
1012 model_pool = self.pool[model]
1013 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
1015 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
1016 (message_dict['message_id'], model)
1019 # disabled subscriptions during message_new/update to avoid having the system user running the
1020 # email gateway become a follower of all inbound messages
1021 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
1022 if thread_id and hasattr(model_pool, 'message_update'):
1023 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
1025 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
1028 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
1029 model_pool = self.pool.get('mail.thread')
1030 if not hasattr(model_pool, 'message_post'):
1031 context['thread_model'] = model
1032 model_pool = self.pool['mail.thread']
1033 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1036 # postponed after message_post, because this is an external message and we don't want to create
1037 # duplicate emails due to notifications
1038 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1041 def message_process(self, cr, uid, model, message, custom_values=None,
1042 save_original=False, strip_attachments=False,
1043 thread_id=None, context=None):
1044 """ Process an incoming RFC2822 email message, relying on
1045 ``mail.message.parse()`` for the parsing operation,
1046 and ``message_route()`` to figure out the target model.
1048 Once the target model is known, its ``message_new`` method
1049 is called with the new message (if the thread record did not exist)
1050 or its ``message_update`` method (if it did).
1052 There is a special case where the target model is False: a reply
1053 to a private message. In this case, we skip the message_new /
1054 message_update step, to just post a new message using mail_thread
1057 :param string model: the fallback model to use if the message
1058 does not match any of the currently configured mail aliases
1059 (may be None if a matching alias is supposed to be present)
1060 :param message: source of the RFC2822 message
1061 :type message: string or xmlrpclib.Binary
1062 :type dict custom_values: optional dictionary of field values
1063 to pass to ``message_new`` if a new record needs to be created.
1064 Ignored if the thread record already exists, and also if a
1065 matching mail.alias was found (aliases define their own defaults)
1066 :param bool save_original: whether to keep a copy of the original
1067 email source attached to the message after it is imported.
1068 :param bool strip_attachments: whether to strip all attachments
1069 before processing the message, in order to save some space.
1070 :param int thread_id: optional ID of the record/thread from ``model``
1071 to which this mail should be attached. When provided, this
1072 overrides the automatic detection based on the message
1078 # extract message bytes - we are forced to pass the message as binary because
1079 # we don't know its encoding until we parse its headers and hence can't
1080 # convert it to utf-8 for transport between the mailgate script and here.
1081 if isinstance(message, xmlrpclib.Binary):
1082 message = str(message.data)
1083 # Warning: message_from_string doesn't always work correctly on unicode,
1084 # we must use utf-8 strings here :-(
1085 if isinstance(message, unicode):
1086 message = message.encode('utf-8')
1087 msg_txt = email.message_from_string(message)
1089 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1090 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1091 if strip_attachments:
1092 msg.pop('attachments', None)
1094 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1095 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1096 ('message_id', '=', msg.get('message_id')),
1098 if existing_msg_ids:
1099 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1100 msg.get('from'), msg.get('to'), msg.get('message_id'))
1103 # find possible routes for the message
1104 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1105 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1108 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1109 """Called by ``message_process`` when a new message is received
1110 for a given thread model, if the message did not belong to
1112 The default behavior is to create a new record of the corresponding
1113 model (based on some very basic info extracted from the message).
1114 Additional behavior may be implemented by overriding this method.
1116 :param dict msg_dict: a map containing the email details and
1117 attachments. See ``message_process`` and
1118 ``mail.message.parse`` for details.
1119 :param dict custom_values: optional dictionary of additional
1120 field values to pass to create()
1121 when creating the new thread record.
1122 Be careful, these values may override
1123 any other values coming from the message.
1124 :param dict context: if a ``thread_model`` value is present
1125 in the context, its value will be used
1126 to determine the model of the record
1127 to create (instead of the current model).
1129 :return: the id of the newly created thread object
1134 if isinstance(custom_values, dict):
1135 data = custom_values.copy()
1136 model = context.get('thread_model') or self._name
1137 model_pool = self.pool[model]
1138 fields = model_pool.fields_get(cr, uid, context=context)
1139 if 'name' in fields and not data.get('name'):
1140 data['name'] = msg_dict.get('subject', '')
1141 res_id = model_pool.create(cr, uid, data, context=context)
1144 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1145 """Called by ``message_process`` when a new message is received
1146 for an existing thread. The default behavior is to update the record
1147 with update_vals taken from the incoming email.
1148 Additional behavior may be implemented by overriding this
1150 :param dict msg_dict: a map containing the email details and
1151 attachments. See ``message_process`` and
1152 ``mail.message.parse()`` for details.
1153 :param dict update_vals: a dict containing values to update records
1154 given their ids; if the dict is None or is
1155 void, no write operation is performed.
1158 self.write(cr, uid, ids, update_vals, context=context)
1161 def _message_extract_payload(self, message, save_original=False):
1162 """Extract body as HTML and attachments from the mail message"""
1166 attachments.append(('original_email.eml', message.as_string()))
1167 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1168 encoding = message.get_content_charset()
1169 body = message.get_payload(decode=True)
1170 body = tools.ustr(body, encoding, errors='replace')
1171 if message.get_content_type() == 'text/plain':
1172 # text/plain -> <pre/>
1173 body = tools.append_content_to_html(u'', body, preserve=True)
1176 for part in message.walk():
1177 if part.get_content_type() == 'multipart/alternative':
1179 if part.get_content_maintype() == 'multipart':
1180 continue # skip container
1181 # part.get_filename returns decoded value if able to decode, coded otherwise.
1182 # original get_filename is not able to decode iso-8859-1 (for instance).
1183 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1184 # code here partially copy the original get_filename method, but handle more encoding
1185 filename=part.get_param('filename', None, 'content-disposition')
1187 filename=part.get_param('name', None)
1189 if isinstance(filename, tuple):
1191 filename=email.utils.collapse_rfc2231_value(filename).strip()
1193 filename=decode(filename)
1194 encoding = part.get_content_charset() # None if attachment
1195 # 1) Explicit Attachments -> attachments
1196 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1197 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1199 # 2) text/plain -> <pre/>
1200 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1201 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1202 encoding, errors='replace'), preserve=True)
1203 # 3) text/html -> raw
1204 elif part.get_content_type() == 'text/html':
1205 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1209 body = tools.append_content_to_html(body, html, plaintext=False)
1210 # 4) Anything else -> attachment
1212 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1213 return body, attachments
1215 def message_parse(self, cr, uid, message, save_original=False, context=None):
1216 """Parses a string or email.message.Message representing an
1217 RFC-2822 email, and returns a generic dict holding the
1220 :param message: the message to parse
1221 :type message: email.message.Message | string | unicode
1222 :param bool save_original: whether the returned dict
1223 should include an ``original`` attachment containing
1224 the source of the message
1226 :return: A dict with the following structure, where each
1227 field may not be present if missing in original
1230 { 'message_id': msg_id,
1235 'body': unified_body,
1236 'attachments': [('file1', 'bytes'),
1243 if not isinstance(message, Message):
1244 if isinstance(message, unicode):
1245 # Warning: message_from_string doesn't always work correctly on unicode,
1246 # we must use utf-8 strings here :-(
1247 message = message.encode('utf-8')
1248 message = email.message_from_string(message)
1250 message_id = message['message-id']
1252 # Very unusual situation, be we should be fault-tolerant here
1253 message_id = "<%s@localhost>" % time.time()
1254 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1255 msg_dict['message_id'] = message_id
1257 if message.get('Subject'):
1258 msg_dict['subject'] = decode(message.get('Subject'))
1260 # Envelope fields not stored in mail.message but made available for message_new()
1261 msg_dict['from'] = decode(message.get('from'))
1262 msg_dict['to'] = decode(message.get('to'))
1263 msg_dict['cc'] = decode(message.get('cc'))
1264 msg_dict['email_from'] = decode(message.get('from'))
1265 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1266 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1268 if message.get('Date'):
1270 date_hdr = decode(message.get('Date'))
1271 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1272 if parsed_date.utcoffset() is None:
1273 # naive datetime, so we arbitrarily decide to make it
1274 # UTC, there's no better choice. Should not happen,
1275 # as RFC2822 requires timezone offset in Date headers.
1276 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1278 stored_date = parsed_date.astimezone(tz=pytz.utc)
1280 _logger.warning('Failed to parse Date header %r in incoming mail '
1281 'with message-id %r, assuming current date/time.',
1282 message.get('Date'), message_id)
1283 stored_date = datetime.datetime.now()
1284 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1286 if message.get('In-Reply-To'):
1287 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1289 msg_dict['parent_id'] = parent_ids[0]
1291 if message.get('References') and 'parent_id' not in msg_dict:
1292 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1293 [x.strip() for x in decode(message['References']).split()])])
1295 msg_dict['parent_id'] = parent_ids[0]
1297 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1300 #------------------------------------------------------
1302 #------------------------------------------------------
1304 def log(self, cr, uid, id, message, secondary=False, context=None):
1305 _logger.warning("log() is deprecated. As this module inherit from "\
1306 "mail.thread, the message will be managed by this "\
1307 "module instead of by the res.log mechanism. Please "\
1308 "use mail_thread.message_post() instead of the "\
1309 "now deprecated res.log.")
1310 self.message_post(cr, uid, [id], message, context=context)
1312 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1313 """ Called by message_get_suggested_recipients, to add a suggested
1314 recipient in the result dictionary. The form is :
1315 partner_id, partner_name<partner_email> or partner_name, reason """
1316 if email and not partner:
1317 # get partner info from email
1318 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1319 if partner_info.get('partner_id'):
1320 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1321 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1323 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1325 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1327 if partner and partner.email: # complete profile: id, name <email>
1328 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1329 elif partner: # incomplete profile: id, name
1330 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1331 else: # unknown partner, we are probably managing an email address
1332 result[obj.id].append((False, email, reason))
1335 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1336 """ Returns suggested recipients for ids. Those are a list of
1337 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1338 result = dict.fromkeys(ids, list())
1339 if self._all_columns.get('user_id'):
1340 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1341 if not obj.user_id or not obj.user_id.partner_id:
1343 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)
1346 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1347 """ Utility method to find partners from email addresses. The rules are :
1348 1 - check in document (model | self, id) followers
1349 2 - try to find a matching partner that is also an user
1350 3 - try to find a matching partner
1352 :param list emails: list of email addresses
1353 :param string model: model to fetch related record; by default self
1355 :param boolean check_followers: check in document followers
1357 partner_obj = self.pool['res.partner']
1360 if id and (model or self._name != 'mail.thread') and check_followers:
1362 obj = self.pool[model].browse(cr, uid, id, context=context)
1364 obj = self.browse(cr, uid, id, context=context)
1365 for contact in emails:
1367 email_address = tools.email_split(contact)
1368 if not email_address:
1369 partner_ids.append(partner_id)
1371 email_address = email_address[0]
1372 # first try: check in document's followers
1374 for follower in obj.message_follower_ids:
1375 if follower.email == email_address:
1376 partner_id = follower.id
1377 # second try: check in partners that are also users
1379 ids = partner_obj.search(cr, SUPERUSER_ID, [
1380 ('email', 'ilike', email_address),
1381 ('user_ids', '!=', False)
1382 ], limit=1, context=context)
1385 # third try: check in partners
1387 ids = partner_obj.search(cr, SUPERUSER_ID, [
1388 ('email', 'ilike', email_address)
1389 ], limit=1, context=context)
1392 partner_ids.append(partner_id)
1395 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1396 """ Convert a list of emails into a list partner_ids and a list
1397 new_partner_ids. The return value is non conventional because
1398 it is meant to be used by the mail widget.
1400 :return dict: partner_ids and new_partner_ids """
1401 mail_message_obj = self.pool.get('mail.message')
1402 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1404 for idx in range(len(emails)):
1405 email_address = emails[idx]
1406 partner_id = partner_ids[idx]
1407 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1408 result.append(partner_info)
1410 # link mail with this from mail to the new partner id
1411 if link_mail and partner_info['partner_id']:
1412 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1414 ('email_from', '=', email_address),
1415 ('email_from', 'ilike', '<%s>' % email_address),
1416 ('author_id', '=', False)
1419 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1422 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1423 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1425 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1426 where content is NOT base64 encoded
1427 :param list attachment_ids: a list of attachment ids, not in tomany command form
1428 :param str attach_model: the model of the attachments parent record
1429 :param integer attach_res_id: the id of the attachments parent record
1431 Attachment = self.pool['ir.attachment']
1432 m2m_attachment_ids = []
1434 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1435 ('res_model', '=', 'mail.compose.message'),
1436 ('create_uid', '=', uid),
1437 ('id', 'in', attachment_ids)], context=context)
1438 if filtered_attachment_ids:
1439 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1440 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1441 # Handle attachments parameter, that is a dictionary of attachments
1442 for name, content in attachments:
1443 if isinstance(content, unicode):
1444 content = content.encode('utf-8')
1447 'datas': base64.b64encode(str(content)),
1448 'datas_fname': name,
1449 'description': name,
1450 'res_model': attach_model,
1451 'res_id': attach_res_id,
1453 m2m_attachment_ids.append((0, 0, data_attach))
1454 return m2m_attachment_ids
1456 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1457 subtype=None, parent_id=False, attachments=None, context=None,
1458 content_subtype='html', **kwargs):
1459 """ Post a new message in an existing thread, returning the new
1462 :param int thread_id: thread ID to post into, or list with one ID;
1463 if False/0, mail.message model will also be set as False
1464 :param str body: body of the message, usually raw HTML that will
1466 :param str type: see mail_message.type field
1467 :param str content_subtype:: if plaintext: convert body into html
1468 :param int parent_id: handle reply to a previous message by adding the
1469 parent partners to the message in case of private discussion
1470 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1471 ``(name,content)``, where content is NOT base64 encoded
1473 Extra keyword arguments will be used as default column values for the
1474 new mail.message record. Special cases:
1475 - attachment_ids: supposed not attached to any document; attach them
1476 to the related document. Should only be set by Chatter.
1477 :return int: ID of newly created mail.message
1481 if attachments is None:
1483 mail_message = self.pool.get('mail.message')
1484 ir_attachment = self.pool.get('ir.attachment')
1486 assert (not thread_id) or \
1487 isinstance(thread_id, (int, long)) or \
1488 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1489 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1490 if isinstance(thread_id, (list, tuple)):
1491 thread_id = thread_id[0]
1493 # if we're processing a message directly coming from the gateway, the destination model was
1494 # set in the context.
1497 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1498 if model != self._name and hasattr(self.pool[model], 'message_post'):
1499 del context['thread_model']
1500 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)
1502 #0: Find the message's author, because we need it for private discussion
1503 author_id = kwargs.get('author_id')
1504 if author_id is None: # keep False values
1505 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1507 # 1: Handle content subtype: if plaintext, converto into HTML
1508 if content_subtype == 'plaintext':
1509 body = tools.plaintext2html(body)
1511 # 2: Private message: add recipients (recipients and author of parent message) - current author
1512 # + legacy-code management (! we manage only 4 and 6 commands)
1514 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1515 for partner_id in kwargs_partner_ids:
1516 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1517 partner_ids.add(partner_id[1])
1518 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1519 partner_ids |= set(partner_id[2])
1520 elif isinstance(partner_id, (int, long)):
1521 partner_ids.add(partner_id)
1523 pass # we do not manage anything else
1524 if parent_id and not model:
1525 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1526 private_followers = set([partner.id for partner in parent_message.partner_ids])
1527 if parent_message.author_id:
1528 private_followers.add(parent_message.author_id.id)
1529 private_followers -= set([author_id])
1530 partner_ids |= private_followers
1533 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1534 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1536 # 4: mail.message.subtype
1539 if '.' not in subtype:
1540 subtype = 'mail.%s' % subtype
1541 subtype_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, subtype)
1543 # automatically subscribe recipients if asked to
1544 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1545 partner_to_subscribe = partner_ids
1546 if context.get('mail_post_autofollow_partner_ids'):
1547 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1548 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1550 # _mail_flat_thread: automatically set free messages to the first posted message
1551 if self._mail_flat_thread and not parent_id and thread_id:
1552 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1553 parent_id = message_ids and message_ids[0] or False
1554 # 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
1556 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1557 # avoid loops when finding ancestors
1560 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1561 while (message.parent_id and message.parent_id.id not in processed_list):
1562 processed_list.append(message.parent_id.id)
1563 message = message.parent_id
1564 parent_id = message.id
1568 'author_id': author_id,
1570 'res_id': thread_id or False,
1572 'subject': subject or False,
1574 'parent_id': parent_id,
1575 'attachment_ids': attachment_ids,
1576 'subtype_id': subtype_id,
1577 'partner_ids': [(4, pid) for pid in partner_ids],
1580 # Avoid warnings about non-existing fields
1581 for x in ('from', 'to', 'cc'):
1585 msg_id = mail_message.create(cr, uid, values, context=context)
1587 # Post-process: subscribe author, update message_last_post
1588 if model and model != 'mail.thread' and thread_id and subtype_id:
1589 # done with SUPERUSER_ID, because on some models users can post only with read access, not necessarily write access
1590 self.write(cr, SUPERUSER_ID, [thread_id], {'message_last_post': fields.datetime.now()}, context=context)
1591 message = mail_message.browse(cr, uid, msg_id, context=context)
1592 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1593 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1596 #------------------------------------------------------
1598 #------------------------------------------------------
1600 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1601 """ Wrapper to get subtypes data. """
1602 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1604 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1605 """ Wrapper on message_subscribe, using users. If user_ids is not
1606 provided, subscribe uid instead. """
1607 if user_ids is None:
1609 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1610 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1612 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1613 """ Add partners to the records followers. """
1616 # not necessary for computation, but saves an access right check
1620 mail_followers_obj = self.pool.get('mail.followers')
1621 subtype_obj = self.pool.get('mail.message.subtype')
1623 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1624 if set(partner_ids) == set([user_pid]):
1626 self.check_access_rights(cr, uid, 'read')
1627 self.check_access_rule(cr, uid, ids, 'read')
1628 except (osv.except_osv, orm.except_orm):
1631 self.check_access_rights(cr, uid, 'write')
1632 self.check_access_rule(cr, uid, ids, 'write')
1634 existing_pids_dict = {}
1635 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1636 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1637 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1639 # subtype_ids specified: update already subscribed partners
1640 if subtype_ids and fol_ids:
1641 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1642 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1643 if subtype_ids is None:
1644 subtype_ids = subtype_obj.search(
1646 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1649 existing_pids = existing_pids_dict.get(id, set())
1650 new_pids = set(partner_ids) - existing_pids
1652 # subscribe new followers
1653 for new_pid in new_pids:
1654 mail_followers_obj.create(
1656 'res_model': self._name,
1658 'partner_id': new_pid,
1659 'subtype_ids': [(6, 0, subtype_ids)],
1664 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1665 """ Wrapper on message_subscribe, using users. If user_ids is not
1666 provided, unsubscribe uid instead. """
1667 if user_ids is None:
1669 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1670 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1672 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1673 """ Remove partners from the records followers. """
1674 # not necessary for computation, but saves an access right check
1677 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1678 if set(partner_ids) == set([user_pid]):
1679 self.check_access_rights(cr, uid, 'read')
1680 self.check_access_rule(cr, uid, ids, 'read')
1682 self.check_access_rights(cr, uid, 'write')
1683 self.check_access_rule(cr, uid, ids, 'write')
1684 fol_obj = self.pool['mail.followers']
1685 fol_ids = fol_obj.search(
1687 ('res_model', '=', self._name),
1688 ('res_id', 'in', ids),
1689 ('partner_id', 'in', partner_ids)
1691 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1693 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1694 """ Returns the list of relational fields linking to res.users that should
1695 trigger an auto subscribe. The default list checks for the fields
1697 - linking to res.users
1698 - with track_visibility set
1699 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1700 project, issue, recruitment, sale.
1701 Override this method if a custom behavior is needed about fields
1702 that automatically subscribe users.
1705 for name, column_info in self._all_columns.items():
1706 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':
1707 user_field_lst.append(name)
1708 return user_field_lst
1710 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1711 """ Handle auto subscription. Two methods for auto subscription exist:
1713 - tracked res.users relational fields, such as user_id fields. Those fields
1714 must be relation fields toward a res.users record, and must have the
1715 track_visilibity attribute set.
1716 - using subtypes parent relationship: check if the current model being
1717 modified has an header record (such as a project for tasks) whose followers
1718 can be added as followers of the current records. Example of structure
1719 with project and task:
1721 - st_project_1.parent_id = st_task_1
1722 - st_project_1.res_model = 'project.project'
1723 - st_project_1.relation_field = 'project_id'
1724 - st_task_1.model = 'project.task'
1726 :param list updated_fields: list of updated fields to track
1727 :param dict values: updated values; if None, the first record will be browsed
1728 to get the values. Added after releasing 7.0, therefore
1729 not merged with updated_fields argumment.
1731 subtype_obj = self.pool.get('mail.message.subtype')
1732 follower_obj = self.pool.get('mail.followers')
1733 new_followers = dict()
1735 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1736 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1738 # fetch header subtypes
1739 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1740 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1742 # if no change in tracked field or no change in tracked relational field: quit
1743 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1744 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1747 # legacy behavior: if values is not given, compute the values by browsing
1748 # @TDENOTE: remove me in 8.0
1750 record = self.browse(cr, uid, ids[0], context=context)
1751 for updated_field in updated_fields:
1752 field_value = getattr(record, updated_field)
1753 if isinstance(field_value, browse_record):
1754 field_value = field_value.id
1755 elif isinstance(field_value, browse_null):
1757 values[updated_field] = field_value
1759 # find followers of headers, update structure for new followers
1761 for subtype in subtypes:
1762 if subtype.relation_field and values.get(subtype.relation_field):
1763 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1765 header_domain = ['|'] * (len(headers) - 1)
1766 for header in headers:
1767 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1768 header_follower_ids = follower_obj.search(
1773 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1774 for subtype in header_follower.subtype_ids:
1775 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1776 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1777 elif subtype.res_model is False:
1778 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1780 # add followers coming from res.users relational fields that are tracked
1781 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1782 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1783 for partner_id in user_pids:
1784 new_followers.setdefault(partner_id, None)
1786 for pid, subtypes in new_followers.items():
1787 subtypes = list(subtypes) if subtypes is not None else None
1788 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1790 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1792 for record_id in ids:
1793 message_obj = self.pool.get('mail.message')
1794 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1795 ('model', '=', self._name),
1796 ('res_id', '=', record_id),
1797 ('type', '=', 'email')], limit=1, context=context)
1799 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1800 ('model', '=', self._name),
1801 ('res_id', '=', record_id)], limit=1, context=context)
1803 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1807 #------------------------------------------------------
1809 #------------------------------------------------------
1811 def message_mark_as_unread(self, cr, uid, ids, context=None):
1812 """ Set as unread. """
1813 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1815 UPDATE mail_notification SET
1818 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1820 ''', (ids, self._name, partner_id))
1823 def message_mark_as_read(self, cr, uid, ids, context=None):
1824 """ Set as read. """
1825 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1827 UPDATE mail_notification SET
1830 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1832 ''', (ids, self._name, partner_id))
1835 #------------------------------------------------------
1837 #------------------------------------------------------
1839 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1840 """Return a list of suggested threads, sorted by the numbers of followers"""
1844 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1845 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1846 if self.pool['res.groups']._all_columns.get('is_portal'):
1847 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1848 if any(group.is_portal for group in user.groups_id):
1852 if removed_suggested_threads is None:
1853 removed_suggested_threads = []
1855 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1856 for thread in self.browse(cr, uid, thread_ids, context=context):
1859 'popularity': len(thread.message_follower_ids),
1860 'name': thread.name,
1861 'image_small': thread.image_small
1863 threads.append(data)
1864 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]
1866 def message_change_thread(self, cr, uid, id, new_res_id, new_model, context=None):
1868 Transfert the list of the mail thread messages from an model to another
1870 :param id : the old res_id of the mail.message
1871 :param new_res_id : the new res_id of the mail.message
1872 :param new_model : the name of the new model of the mail.message
1874 Example : self.pool.get("crm.lead").message_change_thread(self, cr, uid, 2, 4, "project.issue", context)
1875 will transfert thread of the lead (id=2) to the issue (id=4)
1878 # get the sbtype id of the comment Message
1879 subtype_res_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, 'mail.mt_comment', raise_if_not_found=True)
1881 # get the ids of the comment and none-comment of the thread
1882 message_obj = self.pool.get('mail.message')
1883 msg_ids_comment = message_obj.search(cr, uid, [
1884 ('model', '=', self._name),
1885 ('res_id', '=', id),
1886 ('subtype_id', '=', subtype_res_id)], context=context)
1887 msg_ids_not_comment = message_obj.search(cr, uid, [
1888 ('model', '=', self._name),
1889 ('res_id', '=', id),
1890 ('subtype_id', '!=', subtype_res_id)], context=context)
1892 # update the messages
1893 message_obj.write(cr, uid, msg_ids_comment, {"res_id" : new_res_id, "model" : new_model}, context=context)
1894 message_obj.write(cr, uid, msg_ids_not_comment, {"res_id" : new_res_id, "model" : new_model, "subtype_id" : None}, context=context)