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
38 from email.message import Message
39 from email.utils import formataddr
40 from urllib import urlencode
42 from openerp import api, tools
43 from openerp import SUPERUSER_ID
44 from openerp.addons.mail.mail_message import decode
45 from openerp.osv import fields, osv, orm
46 from openerp.osv.orm import BaseModel
47 from openerp.tools.safe_eval import safe_eval as eval
48 from openerp.tools.translate import _
50 _logger = logging.getLogger(__name__)
53 mail_header_msgid_re = re.compile('<[^<>]+>')
55 def decode_header(message, header, separator=' '):
56 return separator.join(map(decode, filter(None, message.get_all(header, []))))
59 class mail_thread(osv.AbstractModel):
60 ''' mail_thread model is meant to be inherited by any model that needs to
61 act as a discussion topic on which messages can be attached. Public
62 methods are prefixed with ``message_`` in order to avoid name
63 collisions with methods of the models that will inherit from this class.
65 ``mail.thread`` defines fields used to handle and display the
66 communication history. ``mail.thread`` also manages followers of
67 inheriting classes. All features and expected behavior are managed
68 by mail.thread. Widgets has been designed for the 7.0 and following
71 Inheriting classes are not required to implement any method, as the
72 default implementation will work for any model. However it is common
73 to override at least the ``message_new`` and ``message_update``
74 methods (calling ``super``) to add model-specific behavior at
75 creation and update of a thread when processing incoming emails.
78 - _mail_flat_thread: if set to True, all messages without parent_id
79 are automatically attached to the first message posted on the
80 ressource. If set to False, the display of Chatter is done using
81 threads, and no parent_id is automatically set.
84 _description = 'Email Thread'
85 _mail_flat_thread = True
86 _mail_post_access = 'write'
88 # Automatic logging system if mail installed
91 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
92 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
99 # :param string field: field name
100 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
101 # :param obj: is a browse_record
102 # :param function lambda: returns whether the tracking should record using this subtype
105 # Mass mailing feature
106 _mail_mass_mailing = False
108 def get_empty_list_help(self, cr, uid, help, context=None):
109 """ Override of BaseModel.get_empty_list_help() to generate an help message
110 that adds alias information. """
111 model = context.get('empty_list_help_model')
112 res_id = context.get('empty_list_help_id')
113 ir_config_parameter = self.pool.get("ir.config_parameter")
114 catchall_domain = ir_config_parameter.get_param(cr, SUPERUSER_ID, "mail.catchall.domain", context=context)
115 document_name = context.get('empty_list_help_document_name', _('document'))
118 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
119 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
120 # check that the alias effectively creates new records
121 if object_id.alias_id and object_id.alias_id.alias_name and \
122 object_id.alias_id.alias_model_id and \
123 object_id.alias_id.alias_model_id.model == self._name and \
124 object_id.alias_id.alias_force_thread_id == 0:
125 alias = object_id.alias_id
126 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
127 alias_obj = self.pool.get('mail.alias')
128 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')
129 if alias_ids and len(alias_ids) == 1:
130 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
133 alias_email = alias.name_get()[0][1]
134 return _("""<p class='oe_view_nocontent_create'>
135 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
139 'document': document_name,
140 'email': alias_email,
141 'static_help': help or ''
144 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
145 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
146 'document': document_name,
147 'static_help': help or '',
152 def _get_message_data(self, cr, uid, ids, name, args, context=None):
154 - message_unread: has uid unread message for the document
155 - message_summary: html snippet summarizing the Chatter for kanban views """
156 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
157 user_pid = self.pool.get('res.users').read(cr, uid, [uid], ['partner_id'], context=context)[0]['partner_id'][0]
159 # search for unread messages, directly in SQL to improve performances
160 cr.execute(""" SELECT m.res_id FROM mail_message m
161 RIGHT JOIN mail_notification n
162 ON (n.message_id = m.id AND n.partner_id = %s AND (n.is_read = False or n.is_read IS NULL))
163 WHERE m.model = %s AND m.res_id in %s""",
164 (user_pid, self._name, tuple(ids),))
165 for result in cr.fetchall():
166 res[result[0]]['message_unread'] = True
167 res[result[0]]['message_unread_count'] += 1
170 if res[id]['message_unread_count']:
171 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
172 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"))
173 res[id].pop('message_unread_count', None)
176 def read_followers_data(self, cr, uid, follower_ids, context=None):
178 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
179 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
180 is_editable = uid in map(lambda x: x.id, technical_group.users)
181 is_uid = uid in map(lambda x: x.id, follower.user_ids)
184 {'is_editable': is_editable, 'is_uid': is_uid},
189 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
191 - message_subtype_data: data about document subtypes: which are
192 available, which are followed if any """
193 res = dict((id, dict(message_subtype_data='')) for id in ids)
195 user_pid = self.pool.get('res.users').read(cr, uid, [uid], ['partner_id'], context=context)[0]['partner_id'][0]
197 # find current model subtypes, add them to a dictionary
198 subtype_obj = self.pool.get('mail.message.subtype')
199 subtype_ids = subtype_obj.search(
201 '&', ('hidden', '=', False), '|', ('res_model', '=', self._name), ('res_model', '=', False)
203 subtype_dict = OrderedDict(
205 'default': subtype.default,
207 'parent_model': subtype.parent_id and subtype.parent_id.res_model or self._name,
209 ) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
211 res[id]['message_subtype_data'] = subtype_dict.copy()
213 # find the document followers, update the data
214 fol_obj = self.pool.get('mail.followers')
215 fol_ids = fol_obj.search(cr, uid, [
216 ('partner_id', '=', user_pid),
217 ('res_id', 'in', ids),
218 ('res_model', '=', self._name),
220 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
221 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
222 for subtype in [st for st in fol.subtype_ids if st.name in thread_subtype_dict]:
223 thread_subtype_dict[subtype.name]['followed'] = True
224 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
228 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
229 return [('message_ids.to_read', '=', True)]
231 def _get_followers(self, cr, uid, ids, name, arg, context=None):
232 fol_obj = self.pool.get('mail.followers')
233 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
234 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
235 user_pid = self.pool.get('res.users').read(cr, uid, [uid], ['partner_id'], context=context)[0]['partner_id'][0]
236 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
237 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
238 if fol.partner_id.id == user_pid:
239 res[fol.res_id]['message_is_follower'] = True
242 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
245 partner_obj = self.pool.get('res.partner')
246 fol_obj = self.pool.get('mail.followers')
248 # read the old set of followers, and determine the new set of followers
249 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
250 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
253 for command in value or []:
254 if isinstance(command, (int, long)):
256 elif command[0] == 0:
257 new.add(partner_obj.create(cr, uid, command[2], context=context))
258 elif command[0] == 1:
259 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
261 elif command[0] == 2:
262 partner_obj.unlink(cr, uid, [command[1]], context=context)
263 new.discard(command[1])
264 elif command[0] == 3:
265 new.discard(command[1])
266 elif command[0] == 4:
268 elif command[0] == 5:
270 elif command[0] == 6:
271 new = set(command[2])
273 # remove partners that are no longer followers
274 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
276 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
278 def _search_followers(self, cr, uid, obj, name, args, context):
279 """Search function for message_follower_ids
281 Do not use with operator 'not in'. Use instead message_is_followers
283 fol_obj = self.pool.get('mail.followers')
285 for field, operator, value in args:
287 # TOFIX make it work with not in
288 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
289 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
290 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
291 res.append(('id', 'in', res_ids))
294 def _search_is_follower(self, cr, uid, obj, name, args, context):
295 """Search function for message_is_follower"""
297 for field, operator, value in args:
299 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
300 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
301 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
302 else: # is not a follower or unknown domain
303 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
304 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
305 res.append(('id', 'in', res_ids))
309 'message_is_follower': fields.function(_get_followers, type='boolean',
310 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
311 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
312 fnct_search=_search_followers, type='many2many', priority=-10,
313 obj='res.partner', string='Followers', multi='_get_followers'),
314 'message_ids': fields.one2many('mail.message', 'res_id',
315 domain=lambda self: [('model', '=', self._name)],
318 help="Messages and communication history"),
319 'message_last_post': fields.datetime('Last Message Date',
320 help='Date of the last message posted on the record.'),
321 'message_unread': fields.function(_get_message_data,
322 fnct_search=_search_message_unread, multi="_get_message_data",
323 type='boolean', string='Unread Messages',
324 help="If checked new messages require your attention."),
325 'message_summary': fields.function(_get_message_data, method=True,
326 type='text', string='Summary', multi="_get_message_data",
327 help="Holds the Chatter summary (number of messages, ...). "\
328 "This summary is directly in html format in order to "\
329 "be inserted in kanban views."),
332 def _get_user_chatter_options(self, cr, uid, context=None):
334 'display_log_button': False
336 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
337 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
338 is_employee = group_user_id in [group.id for group in group_ids]
340 options['display_log_button'] = True
343 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
344 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
345 if view_type == 'form':
346 doc = etree.XML(res['arch'])
347 for node in doc.xpath("//field[@name='message_ids']"):
348 options = json.loads(node.get('options', '{}'))
349 options.update(self._get_user_chatter_options(cr, uid, context=context))
350 node.set('options', json.dumps(options))
351 res['arch'] = etree.tostring(doc)
354 #------------------------------------------------------
355 # CRUD overrides for automatic subscription and logging
356 #------------------------------------------------------
358 def create(self, cr, uid, values, context=None):
359 """ Chatter override :
361 - subscribe followers of parent
362 - log a creation message
367 if context.get('tracking_disable'):
368 return super(mail_thread, self).create(
369 cr, uid, values, context=context)
371 # subscribe uid unless asked not to
372 if not context.get('mail_create_nosubscribe'):
373 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
374 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
375 message_follower_ids.append([4, pid])
376 values['message_follower_ids'] = message_follower_ids
377 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
379 # automatic logging unless asked not to (mainly for various testing purpose)
380 if not context.get('mail_create_nolog'):
381 ir_model_pool = self.pool['ir.model']
382 ids = ir_model_pool.search(cr, uid, [('model', '=', self._name)], context=context)
383 name = ir_model_pool.read(cr, uid, ids, ['name'], context=context)[0]['name']
384 self.message_post(cr, uid, thread_id, body=_('%s created') % name, context=context)
386 # auto_subscribe: take values and defaults into account
387 create_values = dict(values)
388 for key, val in context.iteritems():
389 if key.startswith('default_'):
390 create_values[key[8:]] = val
391 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
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)
400 initial_values = {thread_id: dict.fromkeys(tracked_fields, False)}
401 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
404 def write(self, cr, uid, ids, values, context=None):
407 if isinstance(ids, (int, long)):
409 if context.get('tracking_disable'):
410 return super(mail_thread, self).write(
411 cr, uid, ids, values, context=context)
412 # Track initial values of tracked fields
413 track_ctx = dict(context)
414 if 'lang' not in track_ctx:
415 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
417 tracked_fields = None
418 if not context.get('mail_notrack'):
419 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
422 records = self.browse(cr, uid, ids, context=track_ctx)
423 initial_values = dict((record.id, dict((key, getattr(record, key)) for key in tracked_fields))
424 for record in records)
426 # Perform write, update followers
427 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
428 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
430 # Perform the tracking
432 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
436 def unlink(self, cr, uid, ids, context=None):
437 """ Override unlink to delete messages and followers. This cannot be
438 cascaded, because link is done through (res_model, res_id). """
439 msg_obj = self.pool.get('mail.message')
440 fol_obj = self.pool.get('mail.followers')
441 # delete messages and notifications
442 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
443 msg_obj.unlink(cr, uid, msg_ids, context=context)
445 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
447 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
448 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
451 def copy_data(self, cr, uid, id, default=None, context=None):
452 # avoid tracking multiple temporary changes during copy
453 context = dict(context or {}, mail_notrack=True)
454 return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
456 #------------------------------------------------------
457 # Automatically log tracked fields
458 #------------------------------------------------------
460 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
461 """ Return a structure of tracked fields for the current model.
462 :param list updated_fields: modified field names
463 :return dict: a dict mapping field name to description, containing
464 always tracked fields and modified on_change fields
467 for name, field in self._fields.items():
468 visibility = getattr(field, 'track_visibility', False)
469 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
470 tracked_fields.append(name)
473 return self.fields_get(cr, uid, tracked_fields, context=context)
476 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
478 def convert_for_display(value, col_info):
479 if not value and col_info['type'] == 'boolean':
483 if col_info['type'] == 'many2one':
484 return value.name_get()[0][1]
485 if col_info['type'] == 'selection':
486 return dict(col_info['selection'])[value]
489 def format_message(message_description, tracked_values):
491 if message_description:
492 message = '<span>%s</span>' % message_description
493 for name, change in tracked_values.items():
494 message += '<div> • <b>%s</b>: ' % change.get('col_info')
495 if change.get('old_value'):
496 message += '%s → ' % change.get('old_value')
497 message += '%s</div>' % change.get('new_value')
500 if not tracked_fields:
503 for browse_record in self.browse(cr, uid, ids, context=context):
504 initial = initial_values[browse_record.id]
508 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
509 for col_name, col_info in tracked_fields.items():
510 field = self._fields[col_name]
511 initial_value = initial[col_name]
512 record_value = getattr(browse_record, col_name)
514 if record_value == initial_value and getattr(field, 'track_visibility', None) == 'always':
515 tracked_values[col_name] = dict(
516 col_info=col_info['string'],
517 new_value=convert_for_display(record_value, col_info),
519 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
520 if getattr(field, 'track_visibility', None) in ['always', 'onchange']:
521 tracked_values[col_name] = dict(
522 col_info=col_info['string'],
523 old_value=convert_for_display(initial_value, col_info),
524 new_value=convert_for_display(record_value, col_info),
526 if col_name in tracked_fields:
527 changes.add(col_name)
531 # find subtypes and post messages or log if no subtype found
533 # By passing this key, that allows to let the subtype empty and so don't sent email because partners_to_notify from mail_message._notify will be empty
534 if not context.get('mail_track_log_only'):
535 for field, track_info in self._track.items():
536 if field not in changes:
538 for subtype, method in track_info.items():
539 if method(self, cr, uid, browse_record, context):
540 subtypes.append(subtype)
543 for subtype in subtypes:
544 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
545 if not (subtype_rec and subtype_rec.exists()):
546 _logger.debug('subtype %s not found' % subtype)
548 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
549 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
552 message = format_message('', tracked_values)
553 self.message_post(cr, uid, browse_record.id, body=message, context=context)
556 #------------------------------------------------------
557 # mail.message wrappers and tools
558 #------------------------------------------------------
560 def _needaction_domain_get(self, cr, uid, context=None):
562 return [('message_unread', '=', True)]
565 def _garbage_collect_attachments(self, cr, uid, context=None):
566 """ Garbage collect lost mail attachments. Those are attachments
567 - linked to res_model 'mail.compose.message', the composer wizard
568 - with res_id 0, because they were created outside of an existing
569 wizard (typically user input through Chatter or reports
570 created on-the-fly by the templates)
571 - unused since at least one day (create_date and write_date)
573 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
574 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
575 ir_attachment_obj = self.pool.get('ir.attachment')
576 attach_ids = ir_attachment_obj.search(cr, uid, [
577 ('res_model', '=', 'mail.compose.message'),
579 ('create_date', '<', limit_date_str),
580 ('write_date', '<', limit_date_str),
582 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
585 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
586 """ mail.message check permission rules for related document. This method is
587 meant to be inherited in order to implement addons-specific behavior.
588 A common behavior would be to allow creating messages when having read
589 access rule on the document, for portal document such as issues. """
592 if hasattr(self, '_mail_post_access'):
593 create_allow = self._mail_post_access
595 create_allow = 'write'
597 if operation in ['write', 'unlink']:
598 check_operation = 'write'
599 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
600 check_operation = create_allow
601 elif operation == 'create':
602 check_operation = 'write'
604 check_operation = operation
606 model_obj.check_access_rights(cr, uid, check_operation)
607 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
609 def _get_inbox_action_xml_id(self, cr, uid, context=None):
610 """ When redirecting towards the Inbox, choose which action xml_id has
611 to be fetched. This method is meant to be inherited, at least in portal
612 because portal users have a different Inbox action than classic users. """
613 return ('mail', 'action_mail_inbox_feeds')
615 def message_redirect_action(self, cr, uid, context=None):
616 """ For a given message, return an action that either
617 - opens the form view of the related document if model, res_id, and
618 read access to the document
619 - opens the Inbox with a default search on the conversation if model,
621 - opens the Inbox with context propagated
627 # default action is the Inbox action
628 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
629 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))
630 action = self.pool.get(act_model).read(cr, uid, [act_id], [])[0]
631 params = context.get('params')
632 msg_id = model = res_id = None
635 msg_id = params.get('message_id')
636 model = params.get('model')
637 res_id = params.get('res_id', params.get('id')) # signup automatically generated id instead of res_id
638 if not msg_id and not (model and res_id):
640 if msg_id and not (model and res_id):
641 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
643 model, res_id = msg.model, msg.res_id
645 # if model + res_id found: try to redirect to the document or fallback on the Inbox
647 model_obj = self.pool.get(model)
648 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
650 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
651 action = model_obj.get_access_action(cr, uid, res_id, context=context)
652 except (osv.except_osv, orm.except_orm):
656 'search_default_model': model,
657 'search_default_res_id': res_id,
662 def _get_access_link(self, cr, uid, mail, partner, context=None):
663 # the parameters to encode for the query and fragment part of url
664 query = {'db': cr.dbname}
666 'login': partner.user_ids[0].login,
667 'action': 'mail.action_mail_redirect',
669 if mail.notification:
670 fragment['message_id'] = mail.mail_message_id.id
671 elif mail.model and mail.res_id:
672 fragment.update(model=mail.model, res_id=mail.res_id)
674 return "/web?%s#%s" % (urlencode(query), urlencode(fragment))
676 #------------------------------------------------------
678 #------------------------------------------------------
680 def message_get_default_recipients(self, cr, uid, ids, context=None):
681 if context and context.get('thread_model') and context['thread_model'] in self.pool and context['thread_model'] != self._name:
682 if hasattr(self.pool[context['thread_model']], 'message_get_default_recipients'):
683 sub_ctx = dict(context)
684 sub_ctx.pop('thread_model')
685 return self.pool[context['thread_model']].message_get_default_recipients(cr, uid, ids, context=sub_ctx)
687 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
688 recipient_ids, email_to, email_cc = set(), False, False
689 if 'partner_id' in self._fields and record.partner_id:
690 recipient_ids.add(record.partner_id.id)
691 elif 'email_from' in self._fields and record.email_from:
692 email_to = record.email_from
693 elif 'email' in self._fields:
694 email_to = record.email
695 res[record.id] = {'partner_ids': list(recipient_ids), 'email_to': email_to, 'email_cc': email_cc}
698 def message_get_reply_to(self, cr, uid, ids, default=None, context=None):
699 """ Returns the preferred reply-to email address that is basically
700 the alias of the document, if it exists. """
703 model_name = context.get('thread_model') or self._name
704 alias_domain = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.catchall.domain", context=context)
705 res = dict.fromkeys(ids, False)
707 # alias domain: check for aliases and catchall
711 if model_name and model_name != 'mail.thread':
712 alias_ids = self.pool['mail.alias'].search(
714 ('alias_parent_model_id.model', '=', model_name),
715 ('alias_parent_thread_id', 'in', ids),
716 ('alias_name', '!=', False)
719 dict((alias.alias_parent_thread_id, '%s@%s' % (alias.alias_name, alias_domain))
720 for alias in self.pool['mail.alias'].browse(cr, SUPERUSER_ID, alias_ids, context=context)))
722 dict((ng_res[0], ng_res[1])
723 for ng_res in self.pool[model_name].name_get(cr, SUPERUSER_ID, aliases.keys(), context=context)))
724 # left ids: use catchall
725 left_ids = set(ids).difference(set(aliases.keys()))
727 catchall_alias = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.catchall.alias", context=context)
729 aliases.update(dict((res_id, '%s@%s' % (catchall_alias, alias_domain)) for res_id in left_ids))
730 # compute name of reply-to
731 company_name = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).company_id.name
732 for res_id in aliases.keys():
733 email_name = '%s%s' % (company_name, doc_names.get(res_id) and (' ' + doc_names[res_id]) or '')
734 email_addr = aliases[res_id]
735 res[res_id] = formataddr((email_name, email_addr))
736 left_ids = set(ids).difference(set(aliases.keys()))
737 if left_ids and default:
738 res.update(dict((res_id, default) for res_id in left_ids))
741 def message_get_email_values(self, cr, uid, id, notif_mail=None, context=None):
742 """ Get specific notification email values to store on the notification
743 mail_mail. Void method, inherit it to add custom values. """
747 #------------------------------------------------------
749 #------------------------------------------------------
751 def message_capable_models(self, cr, uid, context=None):
752 """ Used by the plugin addon, based for plugin_outlook and others. """
754 for model_name in self.pool.obj_list():
755 model = self.pool[model_name]
756 if hasattr(model, "message_process") and hasattr(model, "message_post"):
757 ret_dict[model_name] = model._description
760 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
761 """ Find partners related to some header fields of the message.
763 :param string message: an email.message instance """
764 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
765 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
767 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False, context=None):
768 """ Verify route validity. Check and rules:
769 1 - if thread_id -> check that document effectively exists; otherwise
770 fallback on a message_new by resetting thread_id
771 2 - check that message_update exists if thread_id is set; or at least
772 that message_new exist
773 [ - find author_id if udpate_author is set]
774 3 - if there is an alias, check alias_contact:
775 'followers' and thread_id:
776 check on target document that the author is in the followers
777 'followers' and alias_parent_thread_id:
778 check on alias parent document that the author is in the
780 'partners': check that author_id id set
783 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
784 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
786 message_id = message.get('Message-Id')
787 email_from = decode_header(message, 'From')
788 author_id = message_dict.get('author_id')
789 model, thread_id, alias = route[0], route[1], route[4]
792 def _create_bounce_email():
793 mail_mail = self.pool.get('mail.mail')
794 mail_id = mail_mail.create(cr, uid, {
795 'body_html': '<div><p>Hello,</p>'
796 '<p>The following email sent to %s cannot be accepted because this is '
797 'a private email address. Only allowed people can contact us at this address.</p></div>'
798 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
799 'subject': 'Re: %s' % message.get('subject'),
800 'email_to': message.get('from'),
803 mail_mail.send(cr, uid, [mail_id], context=context)
806 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
807 message_id, route, message)
810 if model and not model in self.pool:
812 assert model in self.pool, 'Routing: unknown target model %s' % model
813 _warn('unknown target model %s' % model)
816 model_pool = self.pool[model]
818 # Private message: should not contain any thread_id
819 if not model and thread_id:
822 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
823 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
825 # Private message: should have a parent_id (only answers)
826 if not model and not message_dict.get('parent_id'):
828 if not message_dict.get('parent_id'):
829 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
830 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
833 # Existing Document: check if exists; if not, fallback on create if allowed
834 if thread_id and not model_pool.exists(cr, uid, thread_id):
836 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
839 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
841 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
844 # Existing Document: check model accepts the mailgateway
845 if thread_id and model and not hasattr(model_pool, 'message_update'):
847 _warn('model %s does not accept document update, fall back on document creation' % model)
850 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
852 _warn('model %s does not accept document update, skipping' % model)
855 # New Document: check model accepts the mailgateway
856 if not thread_id and model and not hasattr(model_pool, 'message_new'):
858 if not hasattr(model_pool, 'message_new'):
860 'Model %s does not accept document creation, crashing' % model
862 _warn('model %s does not accept document creation, skipping' % model)
865 # Update message author if asked
866 # We do it now because we need it for aliases (contact settings)
867 if not author_id and update_author:
868 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
870 author_id = author_ids[0]
871 message_dict['author_id'] = author_id
873 # Alias: check alias_contact settings
874 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
876 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
878 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
879 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
880 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
881 _create_bounce_email()
883 elif alias and alias.alias_contact == 'partners' and not author_id:
884 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
885 _create_bounce_email()
888 if not model and not thread_id and not alias and not allow_private:
891 return (model, thread_id, route[2], route[3], route[4])
893 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
894 custom_values=None, context=None):
895 """Attempt to figure out the correct target model, thread_id,
896 custom_values and user_id to use for an incoming message.
897 Multiple values may be returned, if a message had multiple
898 recipients matching existing mail.aliases, for example.
900 The following heuristics are used, in this order:
901 1. If the message replies to an existing thread_id, and
902 properly contains the thread model in the 'In-Reply-To'
903 header, use this model/thread_id pair, and ignore
904 custom_value (not needed as no creation will take place)
905 2. Look for a mail.alias entry matching the message
906 recipient, and use the corresponding model, thread_id,
907 custom_values and user_id.
908 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
910 4. If all the above fails, raise an exception.
912 :param string message: an email.message instance
913 :param dict message_dict: dictionary holding message variables
914 :param string model: the fallback model to use if the message
915 does not match any of the currently configured mail aliases
916 (may be None if a matching alias is supposed to be present)
917 :type dict custom_values: optional dictionary of default field values
918 to pass to ``message_new`` if a new record needs to be created.
919 Ignored if the thread record already exists, and also if a
920 matching mail.alias was found (aliases define their own defaults)
921 :param int thread_id: optional ID of the record/thread from ``model``
922 to which this mail should be attached. Only used if the message
923 does not reply to an existing thread and does not match any mail alias.
924 :return: list of [model, thread_id, custom_values, user_id, alias]
926 :raises: ValueError, TypeError
928 if not isinstance(message, Message):
929 raise TypeError('message must be an email.message.Message at this point')
930 mail_msg_obj = self.pool['mail.message']
931 fallback_model = model
933 # Get email.message.Message variables for future processing
934 message_id = message.get('Message-Id')
935 email_from = decode_header(message, 'From')
936 email_to = decode_header(message, 'To')
937 references = decode_header(message, 'References')
938 in_reply_to = decode_header(message, 'In-Reply-To')
939 thread_references = references or in_reply_to
941 # 1. message is a reply to an existing message (exact match of message_id)
942 ref_match = thread_references and tools.reference_re.search(thread_references)
943 msg_references = mail_header_msgid_re.findall(thread_references)
944 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
945 if ref_match and mail_message_ids:
946 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
947 model, thread_id = original_msg.model, original_msg.res_id
948 route = self.message_route_verify(
949 cr, uid, message, message_dict,
950 (model, thread_id, custom_values, uid, None),
951 update_author=True, assert_model=False, create_fallback=True, context=context)
954 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
955 email_from, email_to, message_id, model, thread_id, custom_values, uid)
958 # 2. message is a reply to an existign thread (6.1 compatibility)
960 reply_thread_id = int(ref_match.group(1))
961 reply_model = ref_match.group(2) or fallback_model
962 reply_hostname = ref_match.group(3)
963 local_hostname = socket.gethostname()
964 # do not match forwarded emails from another OpenERP system (thread_id collision!)
965 if local_hostname == reply_hostname:
966 thread_id, model = reply_thread_id, reply_model
967 if thread_id and model in self.pool:
968 model_obj = self.pool[model]
969 compat_mail_msg_ids = mail_msg_obj.search(
971 ('message_id', '=', False),
972 ('model', '=', model),
973 ('res_id', '=', thread_id),
975 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
976 route = self.message_route_verify(
977 cr, uid, message, message_dict,
978 (model, thread_id, custom_values, uid, None),
979 update_author=True, assert_model=True, create_fallback=True, context=context)
982 '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',
983 email_from, email_to, message_id, model, thread_id, custom_values, uid)
986 # 3. Reply to a private message
988 mail_message_ids = mail_msg_obj.search(cr, uid, [
989 ('message_id', '=', in_reply_to),
990 '!', ('message_id', 'ilike', 'reply_to')
991 ], limit=1, context=context)
993 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
994 route = self.message_route_verify(cr, uid, message, message_dict,
995 (mail_message.model, mail_message.res_id, custom_values, uid, None),
996 update_author=True, assert_model=True, create_fallback=True, allow_private=True, context=context)
999 'Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
1000 email_from, email_to, message_id, mail_message.id, custom_values, uid)
1003 # 4. Look for a matching mail.alias entry
1004 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
1005 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
1007 ','.join([decode_header(message, 'Delivered-To'),
1008 decode_header(message, 'To'),
1009 decode_header(message, 'Cc'),
1010 decode_header(message, 'Resent-To'),
1011 decode_header(message, 'Resent-Cc')])
1012 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
1014 mail_alias = self.pool.get('mail.alias')
1015 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
1018 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
1019 user_id = alias.alias_user_id.id
1021 # TDE note: this could cause crashes, because no clue that the user
1022 # that send the email has the right to create or modify a new document
1023 # Fallback on user_id = uid
1024 # Note: recognized partners will be added as followers anyway
1025 # user_id = self._message_find_user_id(cr, uid, message, context=context)
1027 _logger.info('No matching user_id for the alias %s', alias.alias_name)
1028 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
1029 route = self.message_route_verify(cr, uid, message, message_dict, route,
1030 update_author=True, assert_model=True, create_fallback=True, context=context)
1033 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
1034 email_from, email_to, message_id, route)
1035 routes.append(route)
1038 # 5. Fallback to the provided parameters, if they work
1040 # Legacy: fallback to matching [ID] in the Subject
1041 match = tools.res_re.search(decode_header(message, 'Subject'))
1042 thread_id = match and match.group(1)
1043 # Convert into int (bug spotted in 7.0 because of str)
1045 thread_id = int(thread_id)
1048 route = self.message_route_verify(cr, uid, message, message_dict,
1049 (fallback_model, thread_id, custom_values, uid, None),
1050 update_author=True, assert_model=True, context=context)
1053 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
1054 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
1057 # ValueError if no routes found and if no bounce occured
1059 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
1060 'Create an appropriate mail.alias or force the destination model.' %
1061 (email_from, email_to, message_id)
1064 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
1065 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
1066 context = dict(context or {})
1067 partner_ids = message_dict.pop('partner_ids', [])
1069 for model, thread_id, custom_values, user_id, alias in routes:
1070 if self._name == 'mail.thread':
1071 context['thread_model'] = model
1073 model_pool = self.pool[model]
1074 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
1076 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
1077 (message_dict['message_id'], model)
1080 # disabled subscriptions during message_new/update to avoid having the system user running the
1081 # email gateway become a follower of all inbound messages
1082 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
1083 if thread_id and hasattr(model_pool, 'message_update'):
1084 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
1086 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
1089 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
1090 model_pool = self.pool.get('mail.thread')
1091 if not hasattr(model_pool, 'message_post'):
1092 context['thread_model'] = model
1093 model_pool = self.pool['mail.thread']
1094 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1097 # postponed after message_post, because this is an external message and we don't want to create
1098 # duplicate emails due to notifications
1099 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1102 def message_process(self, cr, uid, model, message, custom_values=None,
1103 save_original=False, strip_attachments=False,
1104 thread_id=None, context=None):
1105 """ Process an incoming RFC2822 email message, relying on
1106 ``mail.message.parse()`` for the parsing operation,
1107 and ``message_route()`` to figure out the target model.
1109 Once the target model is known, its ``message_new`` method
1110 is called with the new message (if the thread record did not exist)
1111 or its ``message_update`` method (if it did).
1113 There is a special case where the target model is False: a reply
1114 to a private message. In this case, we skip the message_new /
1115 message_update step, to just post a new message using mail_thread
1118 :param string model: the fallback model to use if the message
1119 does not match any of the currently configured mail aliases
1120 (may be None if a matching alias is supposed to be present)
1121 :param message: source of the RFC2822 message
1122 :type message: string or xmlrpclib.Binary
1123 :type dict custom_values: optional dictionary of field values
1124 to pass to ``message_new`` if a new record needs to be created.
1125 Ignored if the thread record already exists, and also if a
1126 matching mail.alias was found (aliases define their own defaults)
1127 :param bool save_original: whether to keep a copy of the original
1128 email source attached to the message after it is imported.
1129 :param bool strip_attachments: whether to strip all attachments
1130 before processing the message, in order to save some space.
1131 :param int thread_id: optional ID of the record/thread from ``model``
1132 to which this mail should be attached. When provided, this
1133 overrides the automatic detection based on the message
1139 # extract message bytes - we are forced to pass the message as binary because
1140 # we don't know its encoding until we parse its headers and hence can't
1141 # convert it to utf-8 for transport between the mailgate script and here.
1142 if isinstance(message, xmlrpclib.Binary):
1143 message = str(message.data)
1144 # Warning: message_from_string doesn't always work correctly on unicode,
1145 # we must use utf-8 strings here :-(
1146 if isinstance(message, unicode):
1147 message = message.encode('utf-8')
1148 msg_txt = email.message_from_string(message)
1150 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1151 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1152 if strip_attachments:
1153 msg.pop('attachments', None)
1155 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1156 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1157 ('message_id', '=', msg.get('message_id')),
1159 if existing_msg_ids:
1160 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1161 msg.get('from'), msg.get('to'), msg.get('message_id'))
1164 # find possible routes for the message
1165 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1166 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1169 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1170 """Called by ``message_process`` when a new message is received
1171 for a given thread model, if the message did not belong to
1173 The default behavior is to create a new record of the corresponding
1174 model (based on some very basic info extracted from the message).
1175 Additional behavior may be implemented by overriding this method.
1177 :param dict msg_dict: a map containing the email details and
1178 attachments. See ``message_process`` and
1179 ``mail.message.parse`` for details.
1180 :param dict custom_values: optional dictionary of additional
1181 field values to pass to create()
1182 when creating the new thread record.
1183 Be careful, these values may override
1184 any other values coming from the message.
1185 :param dict context: if a ``thread_model`` value is present
1186 in the context, its value will be used
1187 to determine the model of the record
1188 to create (instead of the current model).
1190 :return: the id of the newly created thread object
1195 if isinstance(custom_values, dict):
1196 data = custom_values.copy()
1197 model = context.get('thread_model') or self._name
1198 model_pool = self.pool[model]
1199 fields = model_pool.fields_get(cr, uid, context=context)
1200 if 'name' in fields and not data.get('name'):
1201 data['name'] = msg_dict.get('subject', '')
1202 res_id = model_pool.create(cr, uid, data, context=context)
1205 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1206 """Called by ``message_process`` when a new message is received
1207 for an existing thread. The default behavior is to update the record
1208 with update_vals taken from the incoming email.
1209 Additional behavior may be implemented by overriding this
1211 :param dict msg_dict: a map containing the email details and
1212 attachments. See ``message_process`` and
1213 ``mail.message.parse()`` for details.
1214 :param dict update_vals: a dict containing values to update records
1215 given their ids; if the dict is None or is
1216 void, no write operation is performed.
1219 self.write(cr, uid, ids, update_vals, context=context)
1222 def _message_extract_payload(self, message, save_original=False):
1223 """Extract body as HTML and attachments from the mail message"""
1227 attachments.append(('original_email.eml', message.as_string()))
1229 # Be careful, content-type may contain tricky content like in the
1230 # following example so test the MIME type with startswith()
1232 # Content-Type: multipart/related;
1233 # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
1235 if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
1236 encoding = message.get_content_charset()
1237 body = message.get_payload(decode=True)
1238 body = tools.ustr(body, encoding, errors='replace')
1239 if message.get_content_type() == 'text/plain':
1240 # text/plain -> <pre/>
1241 body = tools.append_content_to_html(u'', body, preserve=True)
1246 for part in message.walk():
1247 if part.get_content_type() == 'multipart/alternative':
1249 if part.get_content_type() == 'multipart/mixed':
1251 if part.get_content_maintype() == 'multipart':
1252 continue # skip container
1253 # part.get_filename returns decoded value if able to decode, coded otherwise.
1254 # original get_filename is not able to decode iso-8859-1 (for instance).
1255 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1256 # code here partially copy the original get_filename method, but handle more encoding
1257 filename=part.get_param('filename', None, 'content-disposition')
1259 filename=part.get_param('name', None)
1261 if isinstance(filename, tuple):
1263 filename=email.utils.collapse_rfc2231_value(filename).strip()
1265 filename=decode(filename)
1266 encoding = part.get_content_charset() # None if attachment
1267 # 1) Explicit Attachments -> attachments
1268 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1269 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1271 # 2) text/plain -> <pre/>
1272 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1273 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1274 encoding, errors='replace'), preserve=True)
1275 # 3) text/html -> raw
1276 elif part.get_content_type() == 'text/html':
1277 # mutlipart/alternative have one text and a html part, keep only the second
1278 # mixed allows several html parts, append html content
1279 append_content = not alternative or (html and mixed)
1280 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1281 if not append_content:
1284 body = tools.append_content_to_html(body, html, plaintext=False)
1285 # 4) Anything else -> attachment
1287 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1288 return body, attachments
1290 def message_parse(self, cr, uid, message, save_original=False, context=None):
1291 """Parses a string or email.message.Message representing an
1292 RFC-2822 email, and returns a generic dict holding the
1295 :param message: the message to parse
1296 :type message: email.message.Message | string | unicode
1297 :param bool save_original: whether the returned dict
1298 should include an ``original`` attachment containing
1299 the source of the message
1301 :return: A dict with the following structure, where each
1302 field may not be present if missing in original
1305 { 'message_id': msg_id,
1310 'body': unified_body,
1311 'attachments': [('file1', 'bytes'),
1318 if not isinstance(message, Message):
1319 if isinstance(message, unicode):
1320 # Warning: message_from_string doesn't always work correctly on unicode,
1321 # we must use utf-8 strings here :-(
1322 message = message.encode('utf-8')
1323 message = email.message_from_string(message)
1325 message_id = message['message-id']
1327 # Very unusual situation, be we should be fault-tolerant here
1328 message_id = "<%s@localhost>" % time.time()
1329 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1330 msg_dict['message_id'] = message_id
1332 if message.get('Subject'):
1333 msg_dict['subject'] = decode(message.get('Subject'))
1335 # Envelope fields not stored in mail.message but made available for message_new()
1336 msg_dict['from'] = decode(message.get('from'))
1337 msg_dict['to'] = decode(message.get('to'))
1338 msg_dict['cc'] = decode(message.get('cc'))
1339 msg_dict['email_from'] = decode(message.get('from'))
1340 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1341 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1343 if message.get('Date'):
1345 date_hdr = decode(message.get('Date'))
1346 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1347 if parsed_date.utcoffset() is None:
1348 # naive datetime, so we arbitrarily decide to make it
1349 # UTC, there's no better choice. Should not happen,
1350 # as RFC2822 requires timezone offset in Date headers.
1351 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1353 stored_date = parsed_date.astimezone(tz=pytz.utc)
1355 _logger.warning('Failed to parse Date header %r in incoming mail '
1356 'with message-id %r, assuming current date/time.',
1357 message.get('Date'), message_id)
1358 stored_date = datetime.datetime.now()
1359 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1361 if message.get('In-Reply-To'):
1362 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To'].strip()))])
1364 msg_dict['parent_id'] = parent_ids[0]
1366 if message.get('References') and 'parent_id' not in msg_dict:
1367 msg_list = mail_header_msgid_re.findall(decode(message['References']))
1368 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in', [x.strip() for x in msg_list])])
1370 msg_dict['parent_id'] = parent_ids[0]
1372 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1375 #------------------------------------------------------
1377 #------------------------------------------------------
1379 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1380 """ Called by message_get_suggested_recipients, to add a suggested
1381 recipient in the result dictionary. The form is :
1382 partner_id, partner_name<partner_email> or partner_name, reason """
1383 if email and not partner:
1384 # get partner info from email
1385 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1386 if partner_info.get('partner_id'):
1387 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1388 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1390 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1392 if partner and partner.id in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1394 if partner and partner.email: # complete profile: id, name <email>
1395 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1396 elif partner: # incomplete profile: id, name
1397 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1398 else: # unknown partner, we are probably managing an email address
1399 result[obj.id].append((False, email, reason))
1402 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1403 """ Returns suggested recipients for ids. Those are a list of
1404 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1405 result = dict((res_id, []) for res_id in ids)
1406 if 'user_id' in self._fields:
1407 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1408 if not obj.user_id or not obj.user_id.partner_id:
1410 self._message_add_suggested_recipient(cr, uid, result, obj, partner=obj.user_id.partner_id, reason=self._fields['user_id'].string, context=context)
1413 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1414 """ Utility method to find partners from email addresses. The rules are :
1415 1 - check in document (model | self, id) followers
1416 2 - try to find a matching partner that is also an user
1417 3 - try to find a matching partner
1419 :param list emails: list of email addresses
1420 :param string model: model to fetch related record; by default self
1422 :param boolean check_followers: check in document followers
1424 partner_obj = self.pool['res.partner']
1427 if id and (model or self._name != 'mail.thread') and check_followers:
1429 obj = self.pool[model].browse(cr, uid, id, context=context)
1431 obj = self.browse(cr, uid, id, context=context)
1432 for contact in emails:
1434 email_address = tools.email_split(contact)
1435 if not email_address:
1436 partner_ids.append(partner_id)
1438 email_address = email_address[0]
1439 # first try: check in document's followers
1441 for follower in obj.message_follower_ids:
1442 if follower.email == email_address:
1443 partner_id = follower.id
1444 # second try: check in partners that are also users
1446 ids = partner_obj.search(cr, SUPERUSER_ID, [
1447 ('email', 'ilike', email_address),
1448 ('user_ids', '!=', False)
1449 ], limit=1, context=context)
1452 # third try: check in partners
1454 ids = partner_obj.search(cr, SUPERUSER_ID, [
1455 ('email', 'ilike', email_address)
1456 ], limit=1, context=context)
1459 partner_ids.append(partner_id)
1462 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1463 """ Convert a list of emails into a list partner_ids and a list
1464 new_partner_ids. The return value is non conventional because
1465 it is meant to be used by the mail widget.
1467 :return dict: partner_ids and new_partner_ids """
1468 mail_message_obj = self.pool.get('mail.message')
1469 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1471 for idx in range(len(emails)):
1472 email_address = emails[idx]
1473 partner_id = partner_ids[idx]
1474 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1475 result.append(partner_info)
1477 # link mail with this from mail to the new partner id
1478 if link_mail and partner_info['partner_id']:
1479 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1481 ('email_from', '=', email_address),
1482 ('email_from', 'ilike', '<%s>' % email_address),
1483 ('author_id', '=', False)
1486 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1489 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1490 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1492 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1493 where content is NOT base64 encoded
1494 :param list attachment_ids: a list of attachment ids, not in tomany command form
1495 :param str attach_model: the model of the attachments parent record
1496 :param integer attach_res_id: the id of the attachments parent record
1498 Attachment = self.pool['ir.attachment']
1499 m2m_attachment_ids = []
1501 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1502 ('res_model', '=', 'mail.compose.message'),
1503 ('create_uid', '=', uid),
1504 ('id', 'in', attachment_ids)], context=context)
1505 if filtered_attachment_ids:
1506 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1507 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1508 # Handle attachments parameter, that is a dictionary of attachments
1509 for name, content in attachments:
1510 if isinstance(content, unicode):
1511 content = content.encode('utf-8')
1514 'datas': base64.b64encode(str(content)),
1515 'datas_fname': name,
1516 'description': name,
1517 'res_model': attach_model,
1518 'res_id': attach_res_id,
1520 m2m_attachment_ids.append((0, 0, data_attach))
1521 return m2m_attachment_ids
1523 @api.cr_uid_ids_context
1524 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1525 subtype=None, parent_id=False, attachments=None, context=None,
1526 content_subtype='html', **kwargs):
1527 """ Post a new message in an existing thread, returning the new
1530 :param int thread_id: thread ID to post into, or list with one ID;
1531 if False/0, mail.message model will also be set as False
1532 :param str body: body of the message, usually raw HTML that will
1534 :param str type: see mail_message.type field
1535 :param str content_subtype:: if plaintext: convert body into html
1536 :param int parent_id: handle reply to a previous message by adding the
1537 parent partners to the message in case of private discussion
1538 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1539 ``(name,content)``, where content is NOT base64 encoded
1541 Extra keyword arguments will be used as default column values for the
1542 new mail.message record. Special cases:
1543 - attachment_ids: supposed not attached to any document; attach them
1544 to the related document. Should only be set by Chatter.
1545 :return int: ID of newly created mail.message
1549 if attachments is None:
1551 mail_message = self.pool.get('mail.message')
1552 ir_attachment = self.pool.get('ir.attachment')
1554 assert (not thread_id) or \
1555 isinstance(thread_id, (int, long)) or \
1556 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1557 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1558 if isinstance(thread_id, (list, tuple)):
1559 thread_id = thread_id[0]
1561 # if we're processing a message directly coming from the gateway, the destination model was
1562 # set in the context.
1565 model = context.get('thread_model', False) if self._name == 'mail.thread' else self._name
1566 if model and model != self._name and hasattr(self.pool[model], 'message_post'):
1567 del context['thread_model']
1568 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)
1570 #0: Find the message's author, because we need it for private discussion
1571 author_id = kwargs.get('author_id')
1572 if author_id is None: # keep False values
1573 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1575 # 1: Handle content subtype: if plaintext, converto into HTML
1576 if content_subtype == 'plaintext':
1577 body = tools.plaintext2html(body)
1579 # 2: Private message: add recipients (recipients and author of parent message) - current author
1580 # + legacy-code management (! we manage only 4 and 6 commands)
1582 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1583 for partner_id in kwargs_partner_ids:
1584 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1585 partner_ids.add(partner_id[1])
1586 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1587 partner_ids |= set(partner_id[2])
1588 elif isinstance(partner_id, (int, long)):
1589 partner_ids.add(partner_id)
1591 pass # we do not manage anything else
1592 if parent_id and not model:
1593 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1594 private_followers = set([partner.id for partner in parent_message.partner_ids])
1595 if parent_message.author_id:
1596 private_followers.add(parent_message.author_id.id)
1597 private_followers -= set([author_id])
1598 partner_ids |= private_followers
1601 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1602 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1604 # 4: mail.message.subtype
1607 if '.' not in subtype:
1608 subtype = 'mail.%s' % subtype
1609 subtype_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, subtype)
1611 # automatically subscribe recipients if asked to
1612 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1613 partner_to_subscribe = partner_ids
1614 if context.get('mail_post_autofollow_partner_ids'):
1615 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1616 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1618 # _mail_flat_thread: automatically set free messages to the first posted message
1619 if self._mail_flat_thread and model and not parent_id and thread_id:
1620 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model), ('type', '=', 'email')], context=context, order="id ASC", limit=1)
1622 message_ids = message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1623 parent_id = message_ids and message_ids[0] or False
1624 # 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
1626 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1627 # avoid loops when finding ancestors
1630 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1631 while (message.parent_id and message.parent_id.id not in processed_list):
1632 processed_list.append(message.parent_id.id)
1633 message = message.parent_id
1634 parent_id = message.id
1638 'author_id': author_id,
1640 'res_id': model and thread_id or False,
1642 'subject': subject or False,
1644 'parent_id': parent_id,
1645 'attachment_ids': attachment_ids,
1646 'subtype_id': subtype_id,
1647 'partner_ids': [(4, pid) for pid in partner_ids],
1650 # Avoid warnings about non-existing fields
1651 for x in ('from', 'to', 'cc'):
1655 msg_id = mail_message.create(cr, uid, values, context=context)
1657 # Post-process: subscribe author, update message_last_post
1658 if model and model != 'mail.thread' and thread_id and subtype_id:
1659 # done with SUPERUSER_ID, because on some models users can post only with read access, not necessarily write access
1660 self.write(cr, SUPERUSER_ID, [thread_id], {'message_last_post': fields.datetime.now()}, context=context)
1661 message = mail_message.browse(cr, uid, msg_id, context=context)
1662 if message.author_id and model and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1663 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1666 #------------------------------------------------------
1668 #------------------------------------------------------
1670 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1671 """ Wrapper to get subtypes data. """
1672 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1674 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1675 """ Wrapper on message_subscribe, using users. If user_ids is not
1676 provided, subscribe uid instead. """
1677 if user_ids is None:
1679 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1680 result = self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1681 if partner_ids and result:
1682 self.pool['ir.ui.menu'].clear_cache()
1685 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1686 """ Add partners to the records followers. """
1689 # not necessary for computation, but saves an access right check
1693 mail_followers_obj = self.pool.get('mail.followers')
1694 subtype_obj = self.pool.get('mail.message.subtype')
1696 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1697 if set(partner_ids) == set([user_pid]):
1699 self.check_access_rights(cr, uid, 'read')
1700 self.check_access_rule(cr, uid, ids, 'read')
1701 except (osv.except_osv, orm.except_orm):
1704 self.check_access_rights(cr, uid, 'write')
1705 self.check_access_rule(cr, uid, ids, 'write')
1707 existing_pids_dict = {}
1708 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1709 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1710 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1712 # subtype_ids specified: update already subscribed partners
1713 if subtype_ids and fol_ids:
1714 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1715 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1716 if subtype_ids is None:
1717 subtype_ids = subtype_obj.search(
1719 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1722 existing_pids = existing_pids_dict.get(id, set())
1723 new_pids = set(partner_ids) - existing_pids
1725 # subscribe new followers
1726 for new_pid in new_pids:
1727 mail_followers_obj.create(
1729 'res_model': self._name,
1731 'partner_id': new_pid,
1732 'subtype_ids': [(6, 0, subtype_ids)],
1737 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1738 """ Wrapper on message_subscribe, using users. If user_ids is not
1739 provided, unsubscribe uid instead. """
1740 if user_ids is None:
1742 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1743 result = self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1744 if partner_ids and result:
1745 self.pool['ir.ui.menu'].clear_cache()
1748 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1749 """ Remove partners from the records followers. """
1750 # not necessary for computation, but saves an access right check
1753 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1754 if set(partner_ids) == set([user_pid]):
1755 self.check_access_rights(cr, uid, 'read')
1756 self.check_access_rule(cr, uid, ids, 'read')
1758 self.check_access_rights(cr, uid, 'write')
1759 self.check_access_rule(cr, uid, ids, 'write')
1760 fol_obj = self.pool['mail.followers']
1761 fol_ids = fol_obj.search(
1763 ('res_model', '=', self._name),
1764 ('res_id', 'in', ids),
1765 ('partner_id', 'in', partner_ids)
1767 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1769 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=None, context=None):
1770 """ Returns the list of relational fields linking to res.users that should
1771 trigger an auto subscribe. The default list checks for the fields
1773 - linking to res.users
1774 - with track_visibility set
1775 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1776 project, issue, recruitment, sale.
1777 Override this method if a custom behavior is needed about fields
1778 that automatically subscribe users.
1780 if auto_follow_fields is None:
1781 auto_follow_fields = ['user_id']
1783 for name, field in self._fields.items():
1784 if name in auto_follow_fields and name in updated_fields and getattr(field, 'track_visibility', False) and field.comodel_name == 'res.users':
1785 user_field_lst.append(name)
1786 return user_field_lst
1788 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1789 """ Handle auto subscription. Two methods for auto subscription exist:
1791 - tracked res.users relational fields, such as user_id fields. Those fields
1792 must be relation fields toward a res.users record, and must have the
1793 track_visilibity attribute set.
1794 - using subtypes parent relationship: check if the current model being
1795 modified has an header record (such as a project for tasks) whose followers
1796 can be added as followers of the current records. Example of structure
1797 with project and task:
1799 - st_project_1.parent_id = st_task_1
1800 - st_project_1.res_model = 'project.project'
1801 - st_project_1.relation_field = 'project_id'
1802 - st_task_1.model = 'project.task'
1804 :param list updated_fields: list of updated fields to track
1805 :param dict values: updated values; if None, the first record will be browsed
1806 to get the values. Added after releasing 7.0, therefore
1807 not merged with updated_fields argumment.
1809 subtype_obj = self.pool.get('mail.message.subtype')
1810 follower_obj = self.pool.get('mail.followers')
1811 new_followers = dict()
1813 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1814 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1816 # fetch header subtypes
1817 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1818 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1820 # if no change in tracked field or no change in tracked relational field: quit
1821 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1822 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1825 # legacy behavior: if values is not given, compute the values by browsing
1826 # @TDENOTE: remove me in 8.0
1828 record = self.browse(cr, uid, ids[0], context=context)
1829 for updated_field in updated_fields:
1830 field_value = getattr(record, updated_field)
1831 if isinstance(field_value, BaseModel):
1832 field_value = field_value.id
1833 values[updated_field] = field_value
1835 # find followers of headers, update structure for new followers
1837 for subtype in subtypes:
1838 if subtype.relation_field and values.get(subtype.relation_field):
1839 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1841 header_domain = ['|'] * (len(headers) - 1)
1842 for header in headers:
1843 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1844 header_follower_ids = follower_obj.search(
1849 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1850 for subtype in header_follower.subtype_ids:
1851 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1852 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1853 elif subtype.res_model is False:
1854 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1856 # add followers coming from res.users relational fields that are tracked
1857 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1858 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1859 for partner_id in user_pids:
1860 new_followers.setdefault(partner_id, None)
1862 for pid, subtypes in new_followers.items():
1863 subtypes = list(subtypes) if subtypes is not None else None
1864 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1866 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1868 for record_id in ids:
1869 message_obj = self.pool.get('mail.message')
1870 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1871 ('model', '=', self._name),
1872 ('res_id', '=', record_id),
1873 ('type', '=', 'email')], limit=1, context=context)
1875 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1876 ('model', '=', self._name),
1877 ('res_id', '=', record_id)], limit=1, context=context)
1879 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1883 #------------------------------------------------------
1885 #------------------------------------------------------
1887 def message_mark_as_unread(self, cr, uid, ids, context=None):
1888 """ Set as unread. """
1889 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1891 UPDATE mail_notification SET
1894 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1896 ''', (ids, self._name, partner_id))
1897 self.pool.get('mail.notification').invalidate_cache(cr, uid, ['is_read'], context=context)
1900 def message_mark_as_read(self, cr, uid, ids, context=None):
1901 """ Set as read. """
1902 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1904 UPDATE mail_notification SET
1907 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1909 ''', (ids, self._name, partner_id))
1910 self.pool.get('mail.notification').invalidate_cache(cr, uid, ['is_read'], context=context)
1913 #------------------------------------------------------
1915 #------------------------------------------------------
1917 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1918 """Return a list of suggested threads, sorted by the numbers of followers"""
1922 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1923 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1924 if 'is_portal' in self.pool['res.groups']._fields:
1925 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1926 if any(group.is_portal for group in user.groups_id):
1930 if removed_suggested_threads is None:
1931 removed_suggested_threads = []
1933 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1934 for thread in self.browse(cr, uid, thread_ids, context=context):
1937 'popularity': len(thread.message_follower_ids),
1938 'name': thread.name,
1939 'image_small': thread.image_small
1941 threads.append(data)
1942 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]
1944 def message_change_thread(self, cr, uid, id, new_res_id, new_model, context=None):
1946 Transfert the list of the mail thread messages from an model to another
1948 :param id : the old res_id of the mail.message
1949 :param new_res_id : the new res_id of the mail.message
1950 :param new_model : the name of the new model of the mail.message
1952 Example : self.pool.get("crm.lead").message_change_thread(self, cr, uid, 2, 4, "project.issue", context)
1953 will transfert thread of the lead (id=2) to the issue (id=4)
1956 # get the sbtype id of the comment Message
1957 subtype_res_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, 'mail.mt_comment', raise_if_not_found=True)
1959 # get the ids of the comment and none-comment of the thread
1960 message_obj = self.pool.get('mail.message')
1961 msg_ids_comment = message_obj.search(cr, uid, [
1962 ('model', '=', self._name),
1963 ('res_id', '=', id),
1964 ('subtype_id', '=', subtype_res_id)], context=context)
1965 msg_ids_not_comment = message_obj.search(cr, uid, [
1966 ('model', '=', self._name),
1967 ('res_id', '=', id),
1968 ('subtype_id', '!=', subtype_res_id)], context=context)
1970 # update the messages
1971 message_obj.write(cr, uid, msg_ids_comment, {"res_id" : new_res_id, "model" : new_model}, context=context)
1972 message_obj.write(cr, uid, msg_ids_not_comment, {"res_id" : new_res_id, "model" : new_model, "subtype_id" : None}, context=context)