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 ##############################################################################
32 from email.message import Message
34 from openerp import tools
35 from openerp import SUPERUSER_ID
36 from openerp.addons.mail.mail_message import decode
37 from openerp.osv import fields, osv, orm
38 from openerp.osv.orm import browse_record, browse_null
39 from openerp.tools.safe_eval import safe_eval as eval
40 from openerp.tools.translate import _
42 _logger = logging.getLogger(__name__)
45 mail_header_msgid_re = re.compile('<[^<>]+>')
47 def decode_header(message, header, separator=' '):
48 return separator.join(map(decode, filter(None, message.get_all(header, []))))
51 class mail_thread(osv.AbstractModel):
52 ''' mail_thread model is meant to be inherited by any model that needs to
53 act as a discussion topic on which messages can be attached. Public
54 methods are prefixed with ``message_`` in order to avoid name
55 collisions with methods of the models that will inherit from this class.
57 ``mail.thread`` defines fields used to handle and display the
58 communication history. ``mail.thread`` also manages followers of
59 inheriting classes. All features and expected behavior are managed
60 by mail.thread. Widgets has been designed for the 7.0 and following
63 Inheriting classes are not required to implement any method, as the
64 default implementation will work for any model. However it is common
65 to override at least the ``message_new`` and ``message_update``
66 methods (calling ``super``) to add model-specific behavior at
67 creation and update of a thread when processing incoming emails.
70 - _mail_flat_thread: if set to True, all messages without parent_id
71 are automatically attached to the first message posted on the
72 ressource. If set to False, the display of Chatter is done using
73 threads, and no parent_id is automatically set.
76 _description = 'Email Thread'
77 _mail_flat_thread = True
79 # Automatic logging system if mail installed
82 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
83 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
90 # :param string field: field name
91 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
92 # :param obj: is a browse_record
93 # :param function lambda: returns whether the tracking should record using this subtype
96 def _get_message_data(self, cr, uid, ids, name, args, context=None):
98 - message_unread: has uid unread message for the document
99 - message_summary: html snippet summarizing the Chatter for kanban views """
100 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
101 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
103 # search for unread messages, directly in SQL to improve performances
104 cr.execute(""" SELECT m.res_id FROM mail_message m
105 RIGHT JOIN mail_notification n
106 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
107 WHERE m.model = %s AND m.res_id in %s""",
108 (user_pid, self._name, tuple(ids),))
109 for result in cr.fetchall():
110 res[result[0]]['message_unread'] = True
111 res[result[0]]['message_unread_count'] += 1
114 if res[id]['message_unread_count']:
115 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
116 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"))
117 res[id].pop('message_unread_count', None)
120 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
122 - message_subtype_data: data about document subtypes: which are
123 available, which are followed if any """
124 res = dict((id, dict(message_subtype_data='')) for id in ids)
125 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
127 # find current model subtypes, add them to a dictionary
128 subtype_obj = self.pool.get('mail.message.subtype')
129 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
130 subtype_dict = dict((subtype.name, dict(default=subtype.default, followed=False, id=subtype.id)) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
132 res[id]['message_subtype_data'] = subtype_dict.copy()
134 # find the document followers, update the data
135 fol_obj = self.pool.get('mail.followers')
136 fol_ids = fol_obj.search(cr, uid, [
137 ('partner_id', '=', user_pid),
138 ('res_id', 'in', ids),
139 ('res_model', '=', self._name),
141 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
142 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
143 for subtype in fol.subtype_ids:
144 thread_subtype_dict[subtype.name]['followed'] = True
145 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
149 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
150 return [('message_ids.to_read', '=', True)]
152 def _get_followers(self, cr, uid, ids, name, arg, context=None):
153 fol_obj = self.pool.get('mail.followers')
154 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
155 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
156 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
157 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
158 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
159 if fol.partner_id.id == user_pid:
160 res[fol.res_id]['message_is_follower'] = True
163 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
166 partner_obj = self.pool.get('res.partner')
167 fol_obj = self.pool.get('mail.followers')
169 # read the old set of followers, and determine the new set of followers
170 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
171 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
174 for command in value or []:
175 if isinstance(command, (int, long)):
177 elif command[0] == 0:
178 new.add(partner_obj.create(cr, uid, command[2], context=context))
179 elif command[0] == 1:
180 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
182 elif command[0] == 2:
183 partner_obj.unlink(cr, uid, [command[1]], context=context)
184 new.discard(command[1])
185 elif command[0] == 3:
186 new.discard(command[1])
187 elif command[0] == 4:
189 elif command[0] == 5:
191 elif command[0] == 6:
192 new = set(command[2])
194 # remove partners that are no longer followers
195 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
197 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
199 def _search_followers(self, cr, uid, obj, name, args, context):
200 fol_obj = self.pool.get('mail.followers')
202 for field, operator, value in args:
204 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
205 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
206 res.append(('id', 'in', res_ids))
210 'message_is_follower': fields.function(_get_followers,
211 type='boolean', string='Is a Follower', multi='_get_followers,'),
212 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
213 fnct_search=_search_followers, type='many2many', priority=-10,
214 obj='res.partner', string='Followers', multi='_get_followers'),
215 'message_ids': fields.one2many('mail.message', 'res_id',
216 domain=lambda self: [('model', '=', self._name)],
219 help="Messages and communication history"),
220 'message_unread': fields.function(_get_message_data,
221 fnct_search=_search_message_unread, multi="_get_message_data",
222 type='boolean', string='Unread Messages',
223 help="If checked new messages require your attention."),
224 'message_summary': fields.function(_get_message_data, method=True,
225 type='text', string='Summary', multi="_get_message_data",
226 help="Holds the Chatter summary (number of messages, ...). "\
227 "This summary is directly in html format in order to "\
228 "be inserted in kanban views."),
231 #------------------------------------------------------
232 # CRUD overrides for automatic subscription and logging
233 #------------------------------------------------------
235 def create(self, cr, uid, values, context=None):
236 """ Chatter override :
238 - subscribe followers of parent
239 - log a creation message
244 # subscribe uid unless asked not to
245 if not context.get('mail_create_nosubscribe'):
246 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
247 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
248 message_follower_ids.append([4, pid])
249 values['message_follower_ids'] = message_follower_ids
250 # add operation to ignore access rule checking for subscription
251 context_operation = dict(context, operation='create')
253 context_operation = context
254 thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
256 # automatic logging unless asked not to (mainly for various testing purpose)
257 if not context.get('mail_create_nolog'):
258 ir_model_pool = self.pool['ir.model']
259 ids = ir_model_pool.search(cr, uid, [('model', '=', self._name)], context=context)
260 name = ir_model_pool.read(cr, uid, ids, ['name'], context=context)[0]['name']
261 self.message_post(cr, uid, thread_id, body=_('%s created') % name, context=context)
263 # auto_subscribe: take values and defaults into account
264 create_values = dict(values)
265 for key, val in context.iteritems():
266 if key.startswith('default_'):
267 create_values[key[8:]] = val
268 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
271 track_ctx = dict(context)
272 if 'lang' not in track_ctx:
273 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
274 if not context.get('mail_notrack'):
275 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
277 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
278 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
281 def write(self, cr, uid, ids, values, context=None):
284 if isinstance(ids, (int, long)):
287 # Track initial values of tracked fields
288 track_ctx = dict(context)
289 if 'lang' not in track_ctx:
290 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
291 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
293 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=track_ctx)
294 initial_values = dict((item['id'], item) for item in initial)
296 # Perform write, update followers
297 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
298 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
300 if not context.get('mail_notrack'):
301 # Perform the tracking
302 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
304 tracked_fields = None
306 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
309 def unlink(self, cr, uid, ids, context=None):
310 """ Override unlink to delete messages and followers. This cannot be
311 cascaded, because link is done through (res_model, res_id). """
312 msg_obj = self.pool.get('mail.message')
313 fol_obj = self.pool.get('mail.followers')
314 # delete messages and notifications
315 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
316 msg_obj.unlink(cr, uid, msg_ids, context=context)
318 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
320 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
321 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
324 def copy_data(self, cr, uid, id, default=None, context=None):
325 # avoid tracking multiple temporary changes during copy
326 context = dict(context or {}, mail_notrack=True)
328 default = default or {}
329 default['message_ids'] = []
330 default['message_follower_ids'] = []
331 return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
333 #------------------------------------------------------
334 # Automatically log tracked fields
335 #------------------------------------------------------
337 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
338 """ Return a structure of tracked fields for the current model.
339 :param list updated_fields: modified field names
340 :return list: a list of (field_name, column_info obj), containing
341 always tracked fields and modified on_change fields
344 for name, column_info in self._all_columns.items():
345 visibility = getattr(column_info.column, 'track_visibility', False)
346 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
350 return self.fields_get(cr, uid, lst, context=context)
352 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
354 def convert_for_display(value, col_info):
355 if not value and col_info['type'] == 'boolean':
359 if col_info['type'] == 'many2one':
361 if col_info['type'] == 'selection':
362 return dict(col_info['selection'])[value]
365 def format_message(message_description, tracked_values):
367 if message_description:
368 message = '<span>%s</span>' % message_description
369 for name, change in tracked_values.items():
370 message += '<div> • <b>%s</b>: ' % change.get('col_info')
371 if change.get('old_value'):
372 message += '%s → ' % change.get('old_value')
373 message += '%s</div>' % change.get('new_value')
376 if not tracked_fields:
379 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
380 initial = initial_values[record['id']]
384 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
385 for col_name, col_info in tracked_fields.items():
386 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
387 tracked_values[col_name] = dict(col_info=col_info['string'],
388 new_value=convert_for_display(record[col_name], col_info))
389 elif record[col_name] != initial[col_name]:
390 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
391 tracked_values[col_name] = dict(col_info=col_info['string'],
392 old_value=convert_for_display(initial[col_name], col_info),
393 new_value=convert_for_display(record[col_name], col_info))
394 if col_name in tracked_fields:
395 changes.append(col_name)
399 # find subtypes and post messages or log if no subtype found
401 for field, track_info in self._track.items():
402 if field not in changes:
404 for subtype, method in track_info.items():
405 if method(self, cr, uid, record, context):
406 subtypes.append(subtype)
409 for subtype in subtypes:
411 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
412 except ValueError, e:
413 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
415 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
416 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
419 message = format_message('', tracked_values)
420 self.message_post(cr, uid, record['id'], body=message, context=context)
423 #------------------------------------------------------
424 # mail.message wrappers and tools
425 #------------------------------------------------------
427 def _needaction_domain_get(self, cr, uid, context=None):
429 return [('message_unread', '=', True)]
432 def _garbage_collect_attachments(self, cr, uid, context=None):
433 """ Garbage collect lost mail attachments. Those are attachments
434 - linked to res_model 'mail.compose.message', the composer wizard
435 - with res_id 0, because they were created outside of an existing
436 wizard (typically user input through Chatter or reports
437 created on-the-fly by the templates)
438 - unused since at least one day (create_date and write_date)
440 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
441 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
442 ir_attachment_obj = self.pool.get('ir.attachment')
443 attach_ids = ir_attachment_obj.search(cr, uid, [
444 ('res_model', '=', 'mail.compose.message'),
446 ('create_date', '<', limit_date_str),
447 ('write_date', '<', limit_date_str),
449 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
452 #------------------------------------------------------
454 #------------------------------------------------------
456 def message_get_reply_to(self, cr, uid, ids, context=None):
457 if not self._inherits.get('mail.alias'):
458 return [False for id in ids]
459 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
460 if record.get('alias_domain') and record.get('alias_name')
462 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
464 #------------------------------------------------------
466 #------------------------------------------------------
468 def message_capable_models(self, cr, uid, context=None):
469 """ Used by the plugin addon, based for plugin_outlook and others. """
471 for model_name in self.pool.obj_list():
472 model = self.pool.get(model_name)
473 if hasattr(model, "message_process") and hasattr(model, "message_post"):
474 ret_dict[model_name] = model._description
477 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
478 """ Find partners related to some header fields of the message.
480 TDE TODO: merge me with other partner finding methods in 8.0 """
481 partner_obj = self.pool.get('res.partner')
483 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
484 for email_address in tools.email_split(s):
485 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
486 if not related_partners:
487 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address)], limit=1, context=context)
488 partner_ids += related_partners
491 def _message_find_user_id(self, cr, uid, message, context=None):
492 """ TDE TODO: check and maybe merge me with other user finding methods in 8.0 """
493 from_local_part = tools.email_split(decode(message.get('From')))[0]
494 # FP Note: canonification required, the minimu: .lower()
495 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
496 ('login', '=', from_local_part),
497 ('email', '=', from_local_part)], context=context)
498 return user_ids[0] if user_ids else uid
500 def message_route(self, cr, uid, message, model=None, thread_id=None,
501 custom_values=None, context=None):
502 """Attempt to figure out the correct target model, thread_id,
503 custom_values and user_id to use for an incoming message.
504 Multiple values may be returned, if a message had multiple
505 recipients matching existing mail.aliases, for example.
507 The following heuristics are used, in this order:
508 1. If the message replies to an existing thread_id, and
509 properly contains the thread model in the 'In-Reply-To'
510 header, use this model/thread_id pair, and ignore
511 custom_value (not needed as no creation will take place)
512 2. Look for a mail.alias entry matching the message
513 recipient, and use the corresponding model, thread_id,
514 custom_values and user_id.
515 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
517 4. If all the above fails, raise an exception.
519 :param string message: an email.message instance
520 :param string model: the fallback model to use if the message
521 does not match any of the currently configured mail aliases
522 (may be None if a matching alias is supposed to be present)
523 :type dict custom_values: optional dictionary of default field values
524 to pass to ``message_new`` if a new record needs to be created.
525 Ignored if the thread record already exists, and also if a
526 matching mail.alias was found (aliases define their own defaults)
527 :param int thread_id: optional ID of the record/thread from ``model``
528 to which this mail should be attached. Only used if the message
529 does not reply to an existing thread and does not match any mail alias.
530 :return: list of [model, thread_id, custom_values, user_id]
532 :raises: ValueError, TypeError
534 if not isinstance(message, Message):
535 raise TypeError('message must be an email.message.Message at this point')
536 message_id = message.get('Message-Id')
537 email_from = decode_header(message, 'From')
538 email_to = decode_header(message, 'To')
539 references = decode_header(message, 'References')
540 in_reply_to = decode_header(message, 'In-Reply-To')
542 # 1. Verify if this is a reply to an existing thread
543 thread_references = references or in_reply_to
544 ref_match = thread_references and tools.reference_re.search(thread_references)
546 reply_thread_id = int(ref_match.group(1))
547 reply_model = ref_match.group(2) or model
548 reply_hostname = ref_match.group(3)
549 local_hostname = socket.gethostname()
550 # do not match forwarded emails from another OpenERP system (thread_id collision!)
551 if local_hostname == reply_hostname:
552 thread_id, model = reply_thread_id, reply_model
553 model_pool = self.pool.get(model)
554 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
555 and hasattr(model_pool, 'message_update'):
556 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
557 email_from, email_to, message_id, model, thread_id, custom_values, uid)
558 return [(model, thread_id, custom_values, uid)]
560 # Verify whether this is a reply to a private message
562 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
564 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
565 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
566 email_from, email_to, message_id, message.id, custom_values, uid)
567 return [(message.model, message.res_id, custom_values, uid)]
569 # 2. Look for a matching mail.alias entry
570 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
571 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
573 ','.join([decode_header(message, 'Delivered-To'),
574 decode_header(message, 'To'),
575 decode_header(message, 'Cc'),
576 decode_header(message, 'Resent-To'),
577 decode_header(message, 'Resent-Cc')])
578 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
580 mail_alias = self.pool.get('mail.alias')
581 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
584 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
585 user_id = alias.alias_user_id.id
587 # TDE note: this could cause crashes, because no clue that the user
588 # that send the email has the right to create or modify a new document
589 # Fallback on user_id = uid
590 # Note: recognized partners will be added as followers anyway
591 # user_id = self._message_find_user_id(cr, uid, message, context=context)
593 _logger.info('No matching user_id for the alias %s', alias.alias_name)
594 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
595 eval(alias.alias_defaults), user_id))
596 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
597 email_from, email_to, message_id, routes)
600 # 3. Fallback to the provided parameters, if they work
601 model_pool = self.pool.get(model)
603 # Legacy: fallback to matching [ID] in the Subject
604 match = tools.res_re.search(decode_header(message, 'Subject'))
605 thread_id = match and match.group(1)
606 # Convert into int (bug spotted in 7.0 because of str)
608 thread_id = int(thread_id)
611 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
613 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
614 'Create an appropriate mail.alias or force the destination model.' %
615 (email_from, email_to, message_id)
617 if thread_id and not model_pool.exists(cr, uid, thread_id):
618 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
619 thread_id, message_id)
621 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
622 email_from, email_to, message_id, model, thread_id, custom_values, uid)
623 return [(model, thread_id, custom_values, uid)]
625 def message_process(self, cr, uid, model, message, custom_values=None,
626 save_original=False, strip_attachments=False,
627 thread_id=None, context=None):
628 """ Process an incoming RFC2822 email message, relying on
629 ``mail.message.parse()`` for the parsing operation,
630 and ``message_route()`` to figure out the target model.
632 Once the target model is known, its ``message_new`` method
633 is called with the new message (if the thread record did not exist)
634 or its ``message_update`` method (if it did).
636 There is a special case where the target model is False: a reply
637 to a private message. In this case, we skip the message_new /
638 message_update step, to just post a new message using mail_thread
641 :param string model: the fallback model to use if the message
642 does not match any of the currently configured mail aliases
643 (may be None if a matching alias is supposed to be present)
644 :param message: source of the RFC2822 message
645 :type message: string or xmlrpclib.Binary
646 :type dict custom_values: optional dictionary of field values
647 to pass to ``message_new`` if a new record needs to be created.
648 Ignored if the thread record already exists, and also if a
649 matching mail.alias was found (aliases define their own defaults)
650 :param bool save_original: whether to keep a copy of the original
651 email source attached to the message after it is imported.
652 :param bool strip_attachments: whether to strip all attachments
653 before processing the message, in order to save some space.
654 :param int thread_id: optional ID of the record/thread from ``model``
655 to which this mail should be attached. When provided, this
656 overrides the automatic detection based on the message
659 :raises: ValueError, TypeError
664 # extract message bytes - we are forced to pass the message as binary because
665 # we don't know its encoding until we parse its headers and hence can't
666 # convert it to utf-8 for transport between the mailgate script and here.
667 if isinstance(message, xmlrpclib.Binary):
668 message = str(message.data)
669 # Warning: message_from_string doesn't always work correctly on unicode,
670 # we must use utf-8 strings here :-(
671 if isinstance(message, unicode):
672 message = message.encode('utf-8')
673 msg_txt = email.message_from_string(message)
675 # parse the message, verify we are not in a loop by checking message_id is not duplicated
676 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
677 if strip_attachments:
678 msg.pop('attachments', None)
679 if msg.get('message_id'): # should always be True as message_parse generate one if missing
680 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
681 ('message_id', '=', msg.get('message_id')),
684 _logger.info('Ignored mail from %s to %s with Message-Id %s:: found duplicated Message-Id during processing',
685 msg.get('from'), msg.get('to'), msg.get('message_id'))
688 # find possible routes for the message
689 routes = self.message_route(cr, uid, msg_txt, model,
690 thread_id, custom_values,
693 # postpone setting msg.partner_ids after message_post, to avoid double notifications
694 partner_ids = msg.pop('partner_ids', [])
697 for model, thread_id, custom_values, user_id in routes:
698 if self._name == 'mail.thread':
699 context.update({'thread_model': model})
701 model_pool = self.pool.get(model)
702 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
704 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
705 (msg['message_id'], model)
708 # disabled subscriptions during message_new/update to avoid having the system user running the
709 # email gateway become a follower of all inbound messages
710 nosub_ctx = dict(context, mail_create_nosubscribe=True)
711 if thread_id and hasattr(model_pool, 'message_update'):
712 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
714 nosub_ctx = dict(nosub_ctx, mail_create_nolog=True)
715 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
718 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
719 model_pool = self.pool.get('mail.thread')
720 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
723 # postponed after message_post, because this is an external message and we don't want to create
724 # duplicate emails due to notifications
725 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
729 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
730 """Called by ``message_process`` when a new message is received
731 for a given thread model, if the message did not belong to
733 The default behavior is to create a new record of the corresponding
734 model (based on some very basic info extracted from the message).
735 Additional behavior may be implemented by overriding this method.
737 :param dict msg_dict: a map containing the email details and
738 attachments. See ``message_process`` and
739 ``mail.message.parse`` for details.
740 :param dict custom_values: optional dictionary of additional
741 field values to pass to create()
742 when creating the new thread record.
743 Be careful, these values may override
744 any other values coming from the message.
745 :param dict context: if a ``thread_model`` value is present
746 in the context, its value will be used
747 to determine the model of the record
748 to create (instead of the current model).
750 :return: the id of the newly created thread object
755 if isinstance(custom_values, dict):
756 data = custom_values.copy()
757 model = context.get('thread_model') or self._name
758 model_pool = self.pool.get(model)
759 fields = model_pool.fields_get(cr, uid, context=context)
760 if 'name' in fields and not data.get('name'):
761 data['name'] = msg_dict.get('subject', '')
762 res_id = model_pool.create(cr, uid, data, context=context)
765 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
766 """Called by ``message_process`` when a new message is received
767 for an existing thread. The default behavior is to update the record
768 with update_vals taken from the incoming email.
769 Additional behavior may be implemented by overriding this
771 :param dict msg_dict: a map containing the email details and
772 attachments. See ``message_process`` and
773 ``mail.message.parse()`` for details.
774 :param dict update_vals: a dict containing values to update records
775 given their ids; if the dict is None or is
776 void, no write operation is performed.
779 self.write(cr, uid, ids, update_vals, context=context)
782 def _message_extract_payload(self, message, save_original=False):
783 """Extract body as HTML and attachments from the mail message"""
787 attachments.append(('original_email.eml', message.as_string()))
789 # Be careful, content-type may contain tricky content like in the
790 # following example so test the MIME type with startswith()
792 # Content-Type: multipart/related;
793 # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
795 if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
796 encoding = message.get_content_charset()
797 body = message.get_payload(decode=True)
798 body = tools.ustr(body, encoding, errors='replace')
799 if message.get_content_type() == 'text/plain':
800 # text/plain -> <pre/>
801 body = tools.append_content_to_html(u'', body, preserve=True)
806 for part in message.walk():
807 if part.get_content_type() == 'multipart/alternative':
809 if part.get_content_type() == 'multipart/mixed':
811 if part.get_content_maintype() == 'multipart':
812 continue # skip container
813 # part.get_filename returns decoded value if able to decode, coded otherwise.
814 # original get_filename is not able to decode iso-8859-1 (for instance).
815 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
816 # code here partially copy the original get_filename method, but handle more encoding
817 filename=part.get_param('filename', None, 'content-disposition')
819 filename=part.get_param('name', None)
821 if isinstance(filename, tuple):
823 filename=email.utils.collapse_rfc2231_value(filename).strip()
825 filename=decode(filename)
826 encoding = part.get_content_charset() # None if attachment
827 # 1) Explicit Attachments -> attachments
828 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
829 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
831 # 2) text/plain -> <pre/>
832 if part.get_content_type() == 'text/plain' and (not alternative or not body):
833 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
834 encoding, errors='replace'), preserve=True)
835 # 3) text/html -> raw
836 elif part.get_content_type() == 'text/html':
837 # mutlipart/alternative have one text and a html part, keep only the second
838 # mixed allows several html parts, append html content
839 append_content = not alternative or (html and mixed)
840 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
841 if not append_content:
844 body = tools.append_content_to_html(body, html, plaintext=False)
845 # 4) Anything else -> attachment
847 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
848 return body, attachments
850 def message_parse(self, cr, uid, message, save_original=False, context=None):
851 """Parses a string or email.message.Message representing an
852 RFC-2822 email, and returns a generic dict holding the
855 :param message: the message to parse
856 :type message: email.message.Message | string | unicode
857 :param bool save_original: whether the returned dict
858 should include an ``original`` attachment containing
859 the source of the message
861 :return: A dict with the following structure, where each
862 field may not be present if missing in original
865 { 'message_id': msg_id,
870 'body': unified_body,
871 'attachments': [('file1', 'bytes'),
879 if not isinstance(message, Message):
880 if isinstance(message, unicode):
881 # Warning: message_from_string doesn't always work correctly on unicode,
882 # we must use utf-8 strings here :-(
883 message = message.encode('utf-8')
884 message = email.message_from_string(message)
886 message_id = message['message-id']
888 # Very unusual situation, be we should be fault-tolerant here
889 message_id = "<%s@localhost>" % time.time()
890 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
891 msg_dict['message_id'] = message_id
893 if message.get('Subject'):
894 msg_dict['subject'] = decode(message.get('Subject'))
896 # Envelope fields not stored in mail.message but made available for message_new()
897 msg_dict['from'] = decode(message.get('from'))
898 msg_dict['to'] = decode(message.get('to'))
899 msg_dict['cc'] = decode(message.get('cc'))
901 if message.get('From'):
902 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
904 msg_dict['author_id'] = author_ids[0]
905 msg_dict['email_from'] = decode(message.get('from'))
906 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
907 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
909 if message.get('Date'):
911 date_hdr = decode(message.get('Date'))
912 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
913 if parsed_date.utcoffset() is None:
914 # naive datetime, so we arbitrarily decide to make it
915 # UTC, there's no better choice. Should not happen,
916 # as RFC2822 requires timezone offset in Date headers.
917 stored_date = parsed_date.replace(tzinfo=pytz.utc)
919 stored_date = parsed_date.astimezone(tz=pytz.utc)
921 _logger.warning('Failed to parse Date header %r in incoming mail '
922 'with message-id %r, assuming current date/time.',
923 message.get('Date'), message_id)
924 stored_date = datetime.datetime.now()
925 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
927 if message.get('In-Reply-To'):
928 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To'].strip()))])
930 msg_dict['parent_id'] = parent_ids[0]
932 if message.get('References') and 'parent_id' not in msg_dict:
933 msg_list = mail_header_msgid_re.findall(decode(message['References']))
934 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in', [x.strip() for x in msg_list])])
936 msg_dict['parent_id'] = parent_ids[0]
938 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
941 #------------------------------------------------------
943 #------------------------------------------------------
945 def log(self, cr, uid, id, message, secondary=False, context=None):
946 _logger.warning("log() is deprecated. As this module inherit from "\
947 "mail.thread, the message will be managed by this "\
948 "module instead of by the res.log mechanism. Please "\
949 "use mail_thread.message_post() instead of the "\
950 "now deprecated res.log.")
951 self.message_post(cr, uid, [id], message, context=context)
953 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
954 """ Called by message_get_suggested_recipients, to add a suggested
955 recipient in the result dictionary. The form is :
956 partner_id, partner_name<partner_email> or partner_name, reason """
957 if email and not partner:
958 # get partner info from email
959 partner_info = self.message_get_partner_info_from_emails(cr, uid, [email], context=context, res_id=obj.id)
960 if partner_info and partner_info[0].get('partner_id'):
961 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info[0]['partner_id']], context=context)[0]
962 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
964 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
966 if partner and partner.id in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
968 if partner and partner.email: # complete profile: id, name <email>
969 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
970 elif partner: # incomplete profile: id, name
971 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
972 else: # unknown partner, we are probably managing an email address
973 result[obj.id].append((False, email, reason))
976 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
977 """ Returns suggested recipients for ids. Those are a list of
978 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
979 result = dict.fromkeys(ids, list())
980 if self._all_columns.get('user_id'):
981 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
982 if not obj.user_id or not obj.user_id.partner_id:
984 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)
987 def message_get_partner_info_from_emails(self, cr, uid, emails, link_mail=False, context=None, res_id=None):
988 """ Wrapper with weird order parameter because of 7.0 fix.
990 TDE TODO: remove me in 8.0 """
991 return self.message_find_partner_from_emails(cr, uid, res_id, emails, link_mail=link_mail, context=context)
993 def message_find_partner_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
994 """ Convert a list of emails into a list partner_ids and a list
995 new_partner_ids. The return value is non conventional because
996 it is meant to be used by the mail widget.
998 :return dict: partner_ids and new_partner_ids
1000 TDE TODO: merge me with other partner finding methods in 8.0 """
1001 mail_message_obj = self.pool.get('mail.message')
1002 partner_obj = self.pool.get('res.partner')
1004 if id and self._name != 'mail.thread':
1005 obj = self.browse(cr, SUPERUSER_ID, id, context=context)
1008 for email in emails:
1009 partner_info = {'full_name': email, 'partner_id': False}
1010 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
1013 email_address = m.group(3)
1014 # first try: check in document's followers
1016 for follower in obj.message_follower_ids:
1017 if follower.email == email_address:
1018 partner_info['partner_id'] = follower.id
1019 # second try: check in partners
1020 if not partner_info.get('partner_id'):
1021 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
1023 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address)], limit=1, context=context)
1025 partner_info['partner_id'] = ids[0]
1026 result.append(partner_info)
1028 # link mail with this from mail to the new partner id
1029 if link_mail and partner_info['partner_id']:
1030 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1032 ('email_from', '=', email),
1033 ('email_from', 'ilike', '<%s>' % email),
1034 ('author_id', '=', False)
1037 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1040 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1041 subtype=None, parent_id=False, attachments=None, context=None,
1042 content_subtype='html', **kwargs):
1043 """ Post a new message in an existing thread, returning the new
1046 :param int thread_id: thread ID to post into, or list with one ID;
1047 if False/0, mail.message model will also be set as False
1048 :param str body: body of the message, usually raw HTML that will
1050 :param str type: see mail_message.type field
1051 :param str content_subtype:: if plaintext: convert body into html
1052 :param int parent_id: handle reply to a previous message by adding the
1053 parent partners to the message in case of private discussion
1054 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1055 ``(name,content)``, where content is NOT base64 encoded
1057 Extra keyword arguments will be used as default column values for the
1058 new mail.message record. Special cases:
1059 - attachment_ids: supposed not attached to any document; attach them
1060 to the related document. Should only be set by Chatter.
1061 :return int: ID of newly created mail.message
1065 if attachments is None:
1067 mail_message = self.pool.get('mail.message')
1068 ir_attachment = self.pool.get('ir.attachment')
1070 assert (not thread_id) or \
1071 isinstance(thread_id, (int, long)) or \
1072 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1073 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1074 if isinstance(thread_id, (list, tuple)):
1075 thread_id = thread_id[0]
1077 # if we're processing a message directly coming from the gateway, the destination model was
1078 # set in the context.
1081 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1082 if model != self._name:
1083 del context['thread_model']
1084 return self.pool.get(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)
1086 # 0: Parse email-from, try to find a better author_id based on document's followers for incoming emails
1087 email_from = kwargs.get('email_from')
1088 if email_from and thread_id and type == 'email' and kwargs.get('author_id'):
1089 email_list = tools.email_split(email_from)
1090 doc = self.browse(cr, uid, thread_id, context=context)
1091 if email_list and doc:
1092 author_ids = self.pool.get('res.partner').search(cr, uid, [
1093 ('email', 'ilike', email_list[0]),
1094 ('id', 'in', [f.id for f in doc.message_follower_ids])
1095 ], limit=1, context=context)
1097 kwargs['author_id'] = author_ids[0]
1098 author_id = kwargs.get('author_id')
1099 if author_id is None: # keep False values
1100 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1102 # 1: Handle content subtype: if plaintext, converto into HTML
1103 if content_subtype == 'plaintext':
1104 body = tools.plaintext2html(body)
1106 # 2: Private message: add recipients (recipients and author of parent message) - current author
1107 # + legacy-code management (! we manage only 4 and 6 commands)
1109 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1110 for partner_id in kwargs_partner_ids:
1111 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1112 partner_ids.add(partner_id[1])
1113 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1114 partner_ids |= set(partner_id[2])
1115 elif isinstance(partner_id, (int, long)):
1116 partner_ids.add(partner_id)
1118 pass # we do not manage anything else
1119 if parent_id and not model:
1120 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1121 private_followers = set([partner.id for partner in parent_message.partner_ids])
1122 if parent_message.author_id:
1123 private_followers.add(parent_message.author_id.id)
1124 private_followers -= set([author_id])
1125 partner_ids |= private_followers
1128 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1129 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1131 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1132 ('res_model', '=', 'mail.compose.message'),
1133 ('create_uid', '=', uid),
1134 ('id', 'in', attachment_ids)], context=context)
1135 if filtered_attachment_ids:
1136 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1137 attachment_ids = [(4, id) for id in attachment_ids]
1138 # Handle attachments parameter, that is a dictionary of attachments
1139 for name, content in attachments:
1140 if isinstance(content, unicode):
1141 content = content.encode('utf-8')
1144 'datas': base64.b64encode(str(content)),
1145 'datas_fname': name,
1146 'description': name,
1148 'res_id': thread_id,
1150 attachment_ids.append((0, 0, data_attach))
1152 # 4: mail.message.subtype
1155 if '.' not in subtype:
1156 subtype = 'mail.%s' % subtype
1157 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1158 subtype_id = ref and ref[1] or False
1160 # automatically subscribe recipients if asked to
1161 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1162 partner_to_subscribe = partner_ids
1163 if context.get('mail_post_autofollow_partner_ids'):
1164 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1165 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1167 # _mail_flat_thread: automatically set free messages to the first posted message
1168 if self._mail_flat_thread and not parent_id and thread_id:
1169 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model), ('type', '=', 'email')], context=context, order="id ASC", limit=1)
1171 message_ids = message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1172 parent_id = message_ids and message_ids[0] or False
1173 # 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
1175 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1176 # avoid loops when finding ancestors
1179 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1180 while (message.parent_id and message.parent_id.id not in processed_list):
1181 processed_list.append(message.parent_id.id)
1182 message = message.parent_id
1183 parent_id = message.id
1187 'author_id': author_id,
1189 'res_id': thread_id or False,
1191 'subject': subject or False,
1193 'parent_id': parent_id,
1194 'attachment_ids': attachment_ids,
1195 'subtype_id': subtype_id,
1196 'partner_ids': [(4, pid) for pid in partner_ids],
1199 # Avoid warnings about non-existing fields
1200 for x in ('from', 'to', 'cc'):
1203 # Create and auto subscribe the author
1204 msg_id = mail_message.create(cr, uid, values, context=context)
1205 message = mail_message.browse(cr, uid, msg_id, context=context)
1206 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1207 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1210 #------------------------------------------------------
1211 # Compatibility methods: do not use
1212 # TDE TODO: remove me in 8.0
1213 #------------------------------------------------------
1215 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
1216 return {'partner_ids': [], 'new_partner_ids': []}
1218 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
1219 attachment_ids=None, content_subtype='plaintext',
1220 context=None, **kwargs):
1221 return self.message_post(cr, uid, thread_id, body=body, parent_id=parent_id,
1222 attachment_ids=attachment_ids, content_subtype=content_subtype,
1223 context=context, **kwargs)
1225 #------------------------------------------------------
1227 #------------------------------------------------------
1229 def message_get_subscription_data(self, cr, uid, ids, context=None):
1230 """ Wrapper to get subtypes data. """
1231 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1233 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1234 """ Wrapper on message_subscribe, using users. If user_ids is not
1235 provided, subscribe uid instead. """
1236 if user_ids is None:
1238 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1239 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1241 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1242 """ Add partners to the records followers. """
1245 # not necessary for computation, but saves an access right check
1249 mail_followers_obj = self.pool.get('mail.followers')
1250 subtype_obj = self.pool.get('mail.message.subtype')
1252 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1253 if set(partner_ids) == set([user_pid]):
1255 self.check_access_rights(cr, uid, 'read')
1256 if context.get('operation', '') == 'create':
1257 self.check_access_rule(cr, uid, ids, 'create')
1259 self.check_access_rule(cr, uid, ids, 'read')
1260 except (osv.except_osv, orm.except_orm):
1263 self.check_access_rights(cr, uid, 'write')
1264 self.check_access_rule(cr, uid, ids, 'write')
1266 existing_pids_dict = {}
1267 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1268 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1269 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1271 # subtype_ids specified: update already subscribed partners
1272 if subtype_ids and fol_ids:
1273 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1274 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1275 if subtype_ids is None:
1276 subtype_ids = subtype_obj.search(
1278 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1281 existing_pids = existing_pids_dict.get(id, set())
1282 new_pids = set(partner_ids) - existing_pids
1284 # subscribe new followers
1285 for new_pid in new_pids:
1286 mail_followers_obj.create(
1288 'res_model': self._name,
1290 'partner_id': new_pid,
1291 'subtype_ids': [(6, 0, subtype_ids)],
1296 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1297 """ Wrapper on message_subscribe, using users. If user_ids is not
1298 provided, unsubscribe uid instead. """
1299 if user_ids is None:
1301 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1302 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1304 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1305 """ Remove partners from the records followers. """
1306 # not necessary for computation, but saves an access right check
1309 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1310 if set(partner_ids) == set([user_pid]):
1311 self.check_access_rights(cr, uid, 'read')
1312 self.check_access_rule(cr, uid, ids, 'read')
1314 self.check_access_rights(cr, uid, 'write')
1315 self.check_access_rule(cr, uid, ids, 'write')
1316 fol_obj = self.pool['mail.followers']
1317 fol_ids = fol_obj.search(
1319 ('res_model', '=', self._name),
1320 ('res_id', 'in', ids),
1321 ('partner_id', 'in', partner_ids)
1323 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1325 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1326 """ Returns the list of relational fields linking to res.users that should
1327 trigger an auto subscribe. The default list checks for the fields
1329 - linking to res.users
1330 - with track_visibility set
1331 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1332 project, issue, recruitment, sale.
1333 Override this method if a custom behavior is needed about fields
1334 that automatically subscribe users.
1337 for name, column_info in self._all_columns.items():
1338 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':
1339 user_field_lst.append(name)
1340 return user_field_lst
1342 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1343 """ Handle auto subscription. Two methods for auto subscription exist:
1345 - tracked res.users relational fields, such as user_id fields. Those fields
1346 must be relation fields toward a res.users record, and must have the
1347 track_visilibity attribute set.
1348 - using subtypes parent relationship: check if the current model being
1349 modified has an header record (such as a project for tasks) whose followers
1350 can be added as followers of the current records. Example of structure
1351 with project and task:
1353 - st_project_1.parent_id = st_task_1
1354 - st_project_1.res_model = 'project.project'
1355 - st_project_1.relation_field = 'project_id'
1356 - st_task_1.model = 'project.task'
1358 :param list updated_fields: list of updated fields to track
1359 :param dict values: updated values; if None, the first record will be browsed
1360 to get the values. Added after releasing 7.0, therefore
1361 not merged with updated_fields argumment.
1363 subtype_obj = self.pool.get('mail.message.subtype')
1364 follower_obj = self.pool.get('mail.followers')
1365 new_followers = dict()
1367 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1368 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1370 # fetch header subtypes
1371 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1372 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1374 # if no change in tracked field or no change in tracked relational field: quit
1375 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1376 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1379 # legacy behavior: if values is not given, compute the values by browsing
1380 # @TDENOTE: remove me in 8.0
1382 record = self.browse(cr, uid, ids[0], context=context)
1383 for updated_field in updated_fields:
1384 field_value = getattr(record, updated_field)
1385 if isinstance(field_value, browse_record):
1386 field_value = field_value.id
1387 elif isinstance(field_value, browse_null):
1389 values[updated_field] = field_value
1391 # find followers of headers, update structure for new followers
1393 for subtype in subtypes:
1394 if subtype.relation_field and values.get(subtype.relation_field):
1395 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1397 header_domain = ['|'] * (len(headers) - 1)
1398 for header in headers:
1399 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1400 header_follower_ids = follower_obj.search(
1405 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1406 for subtype in header_follower.subtype_ids:
1407 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1408 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1409 elif subtype.res_model is False:
1410 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1412 # add followers coming from res.users relational fields that are tracked
1413 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1414 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1415 for partner_id in user_pids:
1416 new_followers.setdefault(partner_id, None)
1418 for pid, subtypes in new_followers.items():
1419 subtypes = list(subtypes) if subtypes is not None else None
1420 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1422 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1424 for record_id in ids:
1425 message_obj = self.pool.get('mail.message')
1426 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1427 ('model', '=', self._name),
1428 ('res_id', '=', record_id),
1429 ('type', '=', 'email')], limit=1, context=context)
1431 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1432 ('model', '=', self._name),
1433 ('res_id', '=', record_id)], limit=1, context=context)
1435 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1439 #------------------------------------------------------
1441 #------------------------------------------------------
1443 def message_mark_as_unread(self, cr, uid, ids, context=None):
1444 """ Set as unread. """
1445 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1447 UPDATE mail_notification SET
1450 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1452 ''', (ids, self._name, partner_id))
1455 def message_mark_as_read(self, cr, uid, ids, context=None):
1456 """ Set as read. """
1457 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1459 UPDATE mail_notification SET
1462 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1464 ''', (ids, self._name, partner_id))
1467 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: