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 def decode_header(message, header, separator=' '):
46 return separator.join(map(decode, filter(None, message.get_all(header, []))))
49 class mail_thread(osv.AbstractModel):
50 ''' mail_thread model is meant to be inherited by any model that needs to
51 act as a discussion topic on which messages can be attached. Public
52 methods are prefixed with ``message_`` in order to avoid name
53 collisions with methods of the models that will inherit from this class.
55 ``mail.thread`` defines fields used to handle and display the
56 communication history. ``mail.thread`` also manages followers of
57 inheriting classes. All features and expected behavior are managed
58 by mail.thread. Widgets has been designed for the 7.0 and following
61 Inheriting classes are not required to implement any method, as the
62 default implementation will work for any model. However it is common
63 to override at least the ``message_new`` and ``message_update``
64 methods (calling ``super``) to add model-specific behavior at
65 creation and update of a thread when processing incoming emails.
68 - _mail_flat_thread: if set to True, all messages without parent_id
69 are automatically attached to the first message posted on the
70 ressource. If set to False, the display of Chatter is done using
71 threads, and no parent_id is automatically set.
74 _description = 'Email Thread'
75 _mail_flat_thread = True
77 # Automatic logging system if mail installed
80 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
81 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
88 # :param string field: field name
89 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
90 # :param obj: is a browse_record
91 # :param function lambda: returns whether the tracking should record using this subtype
94 def _get_message_data(self, cr, uid, ids, name, args, context=None):
96 - message_unread: has uid unread message for the document
97 - message_summary: html snippet summarizing the Chatter for kanban views """
98 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
99 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
101 # search for unread messages, directly in SQL to improve performances
102 cr.execute(""" SELECT m.res_id FROM mail_message m
103 RIGHT JOIN mail_notification n
104 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
105 WHERE m.model = %s AND m.res_id in %s""",
106 (user_pid, self._name, tuple(ids),))
107 for result in cr.fetchall():
108 res[result[0]]['message_unread'] = True
109 res[result[0]]['message_unread_count'] += 1
112 if res[id]['message_unread_count']:
113 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
114 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"))
115 res[id].pop('message_unread_count', None)
118 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
120 - message_subtype_data: data about document subtypes: which are
121 available, which are followed if any """
122 res = dict((id, dict(message_subtype_data='')) for id in ids)
123 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
125 # find current model subtypes, add them to a dictionary
126 subtype_obj = self.pool.get('mail.message.subtype')
127 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
128 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))
130 res[id]['message_subtype_data'] = subtype_dict.copy()
132 # find the document followers, update the data
133 fol_obj = self.pool.get('mail.followers')
134 fol_ids = fol_obj.search(cr, uid, [
135 ('partner_id', '=', user_pid),
136 ('res_id', 'in', ids),
137 ('res_model', '=', self._name),
139 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
140 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
141 for subtype in fol.subtype_ids:
142 thread_subtype_dict[subtype.name]['followed'] = True
143 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
147 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
148 return [('message_ids.to_read', '=', True)]
150 def _get_followers(self, cr, uid, ids, name, arg, context=None):
151 fol_obj = self.pool.get('mail.followers')
152 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
153 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
154 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
155 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
156 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
157 if fol.partner_id.id == user_pid:
158 res[fol.res_id]['message_is_follower'] = True
161 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
164 partner_obj = self.pool.get('res.partner')
165 fol_obj = self.pool.get('mail.followers')
167 # read the old set of followers, and determine the new set of followers
168 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
169 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
172 for command in value or []:
173 if isinstance(command, (int, long)):
175 elif command[0] == 0:
176 new.add(partner_obj.create(cr, uid, command[2], context=context))
177 elif command[0] == 1:
178 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
180 elif command[0] == 2:
181 partner_obj.unlink(cr, uid, [command[1]], context=context)
182 new.discard(command[1])
183 elif command[0] == 3:
184 new.discard(command[1])
185 elif command[0] == 4:
187 elif command[0] == 5:
189 elif command[0] == 6:
190 new = set(command[2])
192 # remove partners that are no longer followers
193 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
195 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
197 def _search_followers(self, cr, uid, obj, name, args, context):
198 fol_obj = self.pool.get('mail.followers')
200 for field, operator, value in args:
202 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
203 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
204 res.append(('id', 'in', res_ids))
208 'message_is_follower': fields.function(_get_followers,
209 type='boolean', string='Is a Follower', multi='_get_followers,'),
210 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
211 fnct_search=_search_followers, type='many2many', priority=-10,
212 obj='res.partner', string='Followers', multi='_get_followers'),
213 'message_ids': fields.one2many('mail.message', 'res_id',
214 domain=lambda self: [('model', '=', self._name)],
217 help="Messages and communication history"),
218 'message_unread': fields.function(_get_message_data,
219 fnct_search=_search_message_unread, multi="_get_message_data",
220 type='boolean', string='Unread Messages',
221 help="If checked new messages require your attention."),
222 'message_summary': fields.function(_get_message_data, method=True,
223 type='text', string='Summary', multi="_get_message_data",
224 help="Holds the Chatter summary (number of messages, ...). "\
225 "This summary is directly in html format in order to "\
226 "be inserted in kanban views."),
229 #------------------------------------------------------
230 # CRUD overrides for automatic subscription and logging
231 #------------------------------------------------------
233 def create(self, cr, uid, values, context=None):
234 """ Chatter override :
236 - subscribe followers of parent
237 - log a creation message
242 # subscribe uid unless asked not to
243 if not context.get('mail_create_nosubscribe'):
244 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
245 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
246 message_follower_ids.append([4, pid])
247 values['message_follower_ids'] = message_follower_ids
248 # add operation to ignore access rule checking for subscription
249 context_operation = dict(context, operation='create')
251 context_operation = context
252 thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
254 # automatic logging unless asked not to (mainly for various testing purpose)
255 if not context.get('mail_create_nolog'):
256 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
258 # auto_subscribe: take values and defaults into account
259 create_values = dict(values)
260 for key, val in context.iteritems():
261 if key.startswith('default_'):
262 create_values[key[8:]] = val
263 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
266 track_ctx = dict(context)
267 if 'lang' not in track_ctx:
268 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
269 if not context.get('mail_notrack'):
270 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
272 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
273 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
276 def write(self, cr, uid, ids, values, context=None):
279 if isinstance(ids, (int, long)):
282 # Track initial values of tracked fields
283 track_ctx = dict(context)
284 if 'lang' not in track_ctx:
285 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
286 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
288 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=track_ctx)
289 initial_values = dict((item['id'], item) for item in initial)
291 # Perform write, update followers
292 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
293 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
295 if not context.get('mail_notrack'):
296 # Perform the tracking
297 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
299 tracked_fields = None
301 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
304 def unlink(self, cr, uid, ids, context=None):
305 """ Override unlink to delete messages and followers. This cannot be
306 cascaded, because link is done through (res_model, res_id). """
307 msg_obj = self.pool.get('mail.message')
308 fol_obj = self.pool.get('mail.followers')
309 # delete messages and notifications
310 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
311 msg_obj.unlink(cr, uid, msg_ids, context=context)
313 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
315 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
316 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
319 def copy_data(self, cr, uid, id, default=None, context=None):
320 # avoid tracking multiple temporary changes during copy
321 context = dict(context or {}, mail_notrack=True)
323 default = default or {}
324 default['message_ids'] = []
325 default['message_follower_ids'] = []
326 return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
328 #------------------------------------------------------
329 # Automatically log tracked fields
330 #------------------------------------------------------
332 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
333 """ Return a structure of tracked fields for the current model.
334 :param list updated_fields: modified field names
335 :return list: a list of (field_name, column_info obj), containing
336 always tracked fields and modified on_change fields
339 for name, column_info in self._all_columns.items():
340 visibility = getattr(column_info.column, 'track_visibility', False)
341 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
345 return self.fields_get(cr, uid, lst, context=context)
347 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
349 def convert_for_display(value, col_info):
350 if not value and col_info['type'] == 'boolean':
354 if col_info['type'] == 'many2one':
356 if col_info['type'] == 'selection':
357 return dict(col_info['selection'])[value]
360 def format_message(message_description, tracked_values):
362 if message_description:
363 message = '<span>%s</span>' % message_description
364 for name, change in tracked_values.items():
365 message += '<div> • <b>%s</b>: ' % change.get('col_info')
366 if change.get('old_value'):
367 message += '%s → ' % change.get('old_value')
368 message += '%s</div>' % change.get('new_value')
371 if not tracked_fields:
374 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
375 initial = initial_values[record['id']]
379 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
380 for col_name, col_info in tracked_fields.items():
381 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
382 tracked_values[col_name] = dict(col_info=col_info['string'],
383 new_value=convert_for_display(record[col_name], col_info))
384 elif record[col_name] != initial[col_name]:
385 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
386 tracked_values[col_name] = dict(col_info=col_info['string'],
387 old_value=convert_for_display(initial[col_name], col_info),
388 new_value=convert_for_display(record[col_name], col_info))
389 if col_name in tracked_fields:
390 changes.append(col_name)
394 # find subtypes and post messages or log if no subtype found
396 for field, track_info in self._track.items():
397 if field not in changes:
399 for subtype, method in track_info.items():
400 if method(self, cr, uid, record, context):
401 subtypes.append(subtype)
404 for subtype in subtypes:
406 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
407 except ValueError, e:
408 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
410 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
411 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
414 message = format_message('', tracked_values)
415 self.message_post(cr, uid, record['id'], body=message, context=context)
418 #------------------------------------------------------
419 # mail.message wrappers and tools
420 #------------------------------------------------------
422 def _needaction_domain_get(self, cr, uid, context=None):
424 return [('message_unread', '=', True)]
427 def _garbage_collect_attachments(self, cr, uid, context=None):
428 """ Garbage collect lost mail attachments. Those are attachments
429 - linked to res_model 'mail.compose.message', the composer wizard
430 - with res_id 0, because they were created outside of an existing
431 wizard (typically user input through Chatter or reports
432 created on-the-fly by the templates)
433 - unused since at least one day (create_date and write_date)
435 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
436 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
437 ir_attachment_obj = self.pool.get('ir.attachment')
438 attach_ids = ir_attachment_obj.search(cr, uid, [
439 ('res_model', '=', 'mail.compose.message'),
441 ('create_date', '<', limit_date_str),
442 ('write_date', '<', limit_date_str),
444 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
447 #------------------------------------------------------
449 #------------------------------------------------------
451 def message_get_reply_to(self, cr, uid, ids, context=None):
452 if not self._inherits.get('mail.alias'):
453 return [False for id in ids]
454 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
455 if record.get('alias_domain') and record.get('alias_name')
457 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
459 #------------------------------------------------------
461 #------------------------------------------------------
463 def message_capable_models(self, cr, uid, context=None):
464 """ Used by the plugin addon, based for plugin_outlook and others. """
466 for model_name in self.pool.obj_list():
467 model = self.pool.get(model_name)
468 if hasattr(model, "message_process") and hasattr(model, "message_post"):
469 ret_dict[model_name] = model._description
472 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
473 """ Find partners related to some header fields of the message.
475 TDE TODO: merge me with other partner finding methods in 8.0 """
476 partner_obj = self.pool.get('res.partner')
478 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
479 for email_address in tools.email_split(s):
480 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
481 if not related_partners:
482 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address)], limit=1, context=context)
483 partner_ids += related_partners
486 def _message_find_user_id(self, cr, uid, message, context=None):
487 """ TDE TODO: check and maybe merge me with other user finding methods in 8.0 """
488 from_local_part = tools.email_split(decode(message.get('From')))[0]
489 # FP Note: canonification required, the minimu: .lower()
490 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
491 ('login', '=', from_local_part),
492 ('email', '=', from_local_part)], context=context)
493 return user_ids[0] if user_ids else uid
495 def message_route(self, cr, uid, message, model=None, thread_id=None,
496 custom_values=None, context=None):
497 """Attempt to figure out the correct target model, thread_id,
498 custom_values and user_id to use for an incoming message.
499 Multiple values may be returned, if a message had multiple
500 recipients matching existing mail.aliases, for example.
502 The following heuristics are used, in this order:
503 1. If the message replies to an existing thread_id, and
504 properly contains the thread model in the 'In-Reply-To'
505 header, use this model/thread_id pair, and ignore
506 custom_value (not needed as no creation will take place)
507 2. Look for a mail.alias entry matching the message
508 recipient, and use the corresponding model, thread_id,
509 custom_values and user_id.
510 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
512 4. If all the above fails, raise an exception.
514 :param string message: an email.message instance
515 :param string model: the fallback model to use if the message
516 does not match any of the currently configured mail aliases
517 (may be None if a matching alias is supposed to be present)
518 :type dict custom_values: optional dictionary of default field values
519 to pass to ``message_new`` if a new record needs to be created.
520 Ignored if the thread record already exists, and also if a
521 matching mail.alias was found (aliases define their own defaults)
522 :param int thread_id: optional ID of the record/thread from ``model``
523 to which this mail should be attached. Only used if the message
524 does not reply to an existing thread and does not match any mail alias.
525 :return: list of [model, thread_id, custom_values, user_id]
527 :raises: ValueError, TypeError
529 if not isinstance(message, Message):
530 raise TypeError('message must be an email.message.Message at this point')
531 message_id = message.get('Message-Id')
532 email_from = decode_header(message, 'From')
533 email_to = decode_header(message, 'To')
534 references = decode_header(message, 'References')
535 in_reply_to = decode_header(message, 'In-Reply-To')
537 # 1. Verify if this is a reply to an existing thread
538 thread_references = references or in_reply_to
539 ref_match = thread_references and tools.reference_re.search(thread_references)
541 reply_thread_id = int(ref_match.group(1))
542 reply_model = ref_match.group(2) or model
543 reply_hostname = ref_match.group(3)
544 local_hostname = socket.gethostname()
545 # do not match forwarded emails from another OpenERP system (thread_id collision!)
546 if local_hostname == reply_hostname:
547 thread_id, model = reply_thread_id, reply_model
548 model_pool = self.pool.get(model)
549 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
550 and hasattr(model_pool, 'message_update'):
551 _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',
552 email_from, email_to, message_id, model, thread_id, custom_values, uid)
553 return [(model, thread_id, custom_values, uid)]
555 # Verify whether this is a reply to a private message
557 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
559 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
560 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
561 email_from, email_to, message_id, message.id, custom_values, uid)
562 return [(message.model, message.res_id, custom_values, uid)]
564 # 2. Look for a matching mail.alias entry
565 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
566 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
568 ','.join([decode_header(message, 'Delivered-To'),
569 decode_header(message, 'To'),
570 decode_header(message, 'Cc'),
571 decode_header(message, 'Resent-To'),
572 decode_header(message, 'Resent-Cc')])
573 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
575 mail_alias = self.pool.get('mail.alias')
576 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
579 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
580 user_id = alias.alias_user_id.id
582 # TDE note: this could cause crashes, because no clue that the user
583 # that send the email has the right to create or modify a new document
584 # Fallback on user_id = uid
585 # Note: recognized partners will be added as followers anyway
586 # user_id = self._message_find_user_id(cr, uid, message, context=context)
588 _logger.info('No matching user_id for the alias %s', alias.alias_name)
589 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
590 eval(alias.alias_defaults), user_id))
591 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
592 email_from, email_to, message_id, routes)
595 # 3. Fallback to the provided parameters, if they work
596 model_pool = self.pool.get(model)
598 # Legacy: fallback to matching [ID] in the Subject
599 match = tools.res_re.search(decode_header(message, 'Subject'))
600 thread_id = match and match.group(1)
601 # Convert into int (bug spotted in 7.0 because of str)
603 thread_id = int(thread_id)
606 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
608 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
609 'Create an appropriate mail.alias or force the destination model.' %
610 (email_from, email_to, message_id)
612 if thread_id and not model_pool.exists(cr, uid, thread_id):
613 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
614 thread_id, message_id)
616 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
617 email_from, email_to, message_id, model, thread_id, custom_values, uid)
618 return [(model, thread_id, custom_values, uid)]
620 def message_process(self, cr, uid, model, message, custom_values=None,
621 save_original=False, strip_attachments=False,
622 thread_id=None, context=None):
623 """ Process an incoming RFC2822 email message, relying on
624 ``mail.message.parse()`` for the parsing operation,
625 and ``message_route()`` to figure out the target model.
627 Once the target model is known, its ``message_new`` method
628 is called with the new message (if the thread record did not exist)
629 or its ``message_update`` method (if it did).
631 There is a special case where the target model is False: a reply
632 to a private message. In this case, we skip the message_new /
633 message_update step, to just post a new message using mail_thread
636 :param string model: the fallback model to use if the message
637 does not match any of the currently configured mail aliases
638 (may be None if a matching alias is supposed to be present)
639 :param message: source of the RFC2822 message
640 :type message: string or xmlrpclib.Binary
641 :type dict custom_values: optional dictionary of field values
642 to pass to ``message_new`` if a new record needs to be created.
643 Ignored if the thread record already exists, and also if a
644 matching mail.alias was found (aliases define their own defaults)
645 :param bool save_original: whether to keep a copy of the original
646 email source attached to the message after it is imported.
647 :param bool strip_attachments: whether to strip all attachments
648 before processing the message, in order to save some space.
649 :param int thread_id: optional ID of the record/thread from ``model``
650 to which this mail should be attached. When provided, this
651 overrides the automatic detection based on the message
654 :raises: ValueError, TypeError
659 # extract message bytes - we are forced to pass the message as binary because
660 # we don't know its encoding until we parse its headers and hence can't
661 # convert it to utf-8 for transport between the mailgate script and here.
662 if isinstance(message, xmlrpclib.Binary):
663 message = str(message.data)
664 # Warning: message_from_string doesn't always work correctly on unicode,
665 # we must use utf-8 strings here :-(
666 if isinstance(message, unicode):
667 message = message.encode('utf-8')
668 msg_txt = email.message_from_string(message)
670 # parse the message, verify we are not in a loop by checking message_id is not duplicated
671 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
672 if strip_attachments:
673 msg.pop('attachments', None)
674 if msg.get('message_id'): # should always be True as message_parse generate one if missing
675 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
676 ('message_id', '=', msg.get('message_id')),
679 _logger.info('Ignored mail from %s to %s with Message-Id %s:: found duplicated Message-Id during processing',
680 msg.get('from'), msg.get('to'), msg.get('message_id'))
683 # find possible routes for the message
684 routes = self.message_route(cr, uid, msg_txt, model,
685 thread_id, custom_values,
688 # postpone setting msg.partner_ids after message_post, to avoid double notifications
689 partner_ids = msg.pop('partner_ids', [])
692 for model, thread_id, custom_values, user_id in routes:
693 if self._name == 'mail.thread':
694 context.update({'thread_model': model})
696 model_pool = self.pool.get(model)
697 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
699 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
700 (msg['message_id'], model)
703 # disabled subscriptions during message_new/update to avoid having the system user running the
704 # email gateway become a follower of all inbound messages
705 nosub_ctx = dict(context, mail_create_nosubscribe=True)
706 if thread_id and hasattr(model_pool, 'message_update'):
707 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
709 nosub_ctx = dict(nosub_ctx, mail_create_nolog=True)
710 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
713 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
714 model_pool = self.pool.get('mail.thread')
715 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
718 # postponed after message_post, because this is an external message and we don't want to create
719 # duplicate emails due to notifications
720 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
724 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
725 """Called by ``message_process`` when a new message is received
726 for a given thread model, if the message did not belong to
728 The default behavior is to create a new record of the corresponding
729 model (based on some very basic info extracted from the message).
730 Additional behavior may be implemented by overriding this method.
732 :param dict msg_dict: a map containing the email details and
733 attachments. See ``message_process`` and
734 ``mail.message.parse`` for details.
735 :param dict custom_values: optional dictionary of additional
736 field values to pass to create()
737 when creating the new thread record.
738 Be careful, these values may override
739 any other values coming from the message.
740 :param dict context: if a ``thread_model`` value is present
741 in the context, its value will be used
742 to determine the model of the record
743 to create (instead of the current model).
745 :return: the id of the newly created thread object
750 if isinstance(custom_values, dict):
751 data = custom_values.copy()
752 model = context.get('thread_model') or self._name
753 model_pool = self.pool.get(model)
754 fields = model_pool.fields_get(cr, uid, context=context)
755 if 'name' in fields and not data.get('name'):
756 data['name'] = msg_dict.get('subject', '')
757 res_id = model_pool.create(cr, uid, data, context=context)
760 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
761 """Called by ``message_process`` when a new message is received
762 for an existing thread. The default behavior is to update the record
763 with update_vals taken from the incoming email.
764 Additional behavior may be implemented by overriding this
766 :param dict msg_dict: a map containing the email details and
767 attachments. See ``message_process`` and
768 ``mail.message.parse()`` for details.
769 :param dict update_vals: a dict containing values to update records
770 given their ids; if the dict is None or is
771 void, no write operation is performed.
774 self.write(cr, uid, ids, update_vals, context=context)
777 def _message_extract_payload(self, message, save_original=False):
778 """Extract body as HTML and attachments from the mail message"""
782 attachments.append(('original_email.eml', message.as_string()))
784 # Be careful, content-type may contain tricky content like in the
785 # following example so test the MIME type with startswith()
787 # Content-Type: multipart/related;
788 # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
790 if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
791 encoding = message.get_content_charset()
792 body = message.get_payload(decode=True)
793 body = tools.ustr(body, encoding, errors='replace')
794 if message.get_content_type() == 'text/plain':
795 # text/plain -> <pre/>
796 body = tools.append_content_to_html(u'', body, preserve=True)
799 for part in message.walk():
800 if part.get_content_type() == 'multipart/alternative':
802 if part.get_content_maintype() == 'multipart':
803 continue # skip container
804 # part.get_filename returns decoded value if able to decode, coded otherwise.
805 # original get_filename is not able to decode iso-8859-1 (for instance).
806 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
807 # code here partially copy the original get_filename method, but handle more encoding
808 filename=part.get_param('filename', None, 'content-disposition')
810 filename=part.get_param('name', None)
812 if isinstance(filename, tuple):
814 filename=email.utils.collapse_rfc2231_value(filename).strip()
816 filename=decode(filename)
817 encoding = part.get_content_charset() # None if attachment
818 # 1) Explicit Attachments -> attachments
819 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
820 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
822 # 2) text/plain -> <pre/>
823 if part.get_content_type() == 'text/plain' and (not alternative or not body):
824 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
825 encoding, errors='replace'), preserve=True)
826 # 3) text/html -> raw
827 elif part.get_content_type() == 'text/html':
828 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
832 body = tools.append_content_to_html(body, html, plaintext=False)
833 # 4) Anything else -> attachment
835 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
836 return body, attachments
838 def message_parse(self, cr, uid, message, save_original=False, context=None):
839 """Parses a string or email.message.Message representing an
840 RFC-2822 email, and returns a generic dict holding the
843 :param message: the message to parse
844 :type message: email.message.Message | string | unicode
845 :param bool save_original: whether the returned dict
846 should include an ``original`` attachment containing
847 the source of the message
849 :return: A dict with the following structure, where each
850 field may not be present if missing in original
853 { 'message_id': msg_id,
858 'body': unified_body,
859 'attachments': [('file1', 'bytes'),
867 if not isinstance(message, Message):
868 if isinstance(message, unicode):
869 # Warning: message_from_string doesn't always work correctly on unicode,
870 # we must use utf-8 strings here :-(
871 message = message.encode('utf-8')
872 message = email.message_from_string(message)
874 message_id = message['message-id']
876 # Very unusual situation, be we should be fault-tolerant here
877 message_id = "<%s@localhost>" % time.time()
878 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
879 msg_dict['message_id'] = message_id
881 if message.get('Subject'):
882 msg_dict['subject'] = decode(message.get('Subject'))
884 # Envelope fields not stored in mail.message but made available for message_new()
885 msg_dict['from'] = decode(message.get('from'))
886 msg_dict['to'] = decode(message.get('to'))
887 msg_dict['cc'] = decode(message.get('cc'))
889 if message.get('From'):
890 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
892 msg_dict['author_id'] = author_ids[0]
893 msg_dict['email_from'] = decode(message.get('from'))
894 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
895 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
897 if message.get('Date'):
899 date_hdr = decode(message.get('Date'))
900 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
901 if parsed_date.utcoffset() is None:
902 # naive datetime, so we arbitrarily decide to make it
903 # UTC, there's no better choice. Should not happen,
904 # as RFC2822 requires timezone offset in Date headers.
905 stored_date = parsed_date.replace(tzinfo=pytz.utc)
907 stored_date = parsed_date.astimezone(tz=pytz.utc)
909 _logger.warning('Failed to parse Date header %r in incoming mail '
910 'with message-id %r, assuming current date/time.',
911 message.get('Date'), message_id)
912 stored_date = datetime.datetime.now()
913 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
915 if message.get('In-Reply-To'):
916 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
918 msg_dict['parent_id'] = parent_ids[0]
920 if message.get('References') and 'parent_id' not in msg_dict:
921 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
922 [x.strip() for x in decode(message['References']).split()])])
924 msg_dict['parent_id'] = parent_ids[0]
926 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
929 #------------------------------------------------------
931 #------------------------------------------------------
933 def log(self, cr, uid, id, message, secondary=False, context=None):
934 _logger.warning("log() is deprecated. As this module inherit from "\
935 "mail.thread, the message will be managed by this "\
936 "module instead of by the res.log mechanism. Please "\
937 "use mail_thread.message_post() instead of the "\
938 "now deprecated res.log.")
939 self.message_post(cr, uid, [id], message, context=context)
941 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
942 """ Called by message_get_suggested_recipients, to add a suggested
943 recipient in the result dictionary. The form is :
944 partner_id, partner_name<partner_email> or partner_name, reason """
945 if email and not partner:
946 # get partner info from email
947 partner_info = self.message_get_partner_info_from_emails(cr, uid, [email], context=context, res_id=obj.id)
948 if partner_info and partner_info[0].get('partner_id'):
949 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info[0]['partner_id']], context=context)[0]
950 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
952 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
954 if partner and partner.id in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
956 if partner and partner.email: # complete profile: id, name <email>
957 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
958 elif partner: # incomplete profile: id, name
959 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
960 else: # unknown partner, we are probably managing an email address
961 result[obj.id].append((False, email, reason))
964 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
965 """ Returns suggested recipients for ids. Those are a list of
966 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
967 result = dict.fromkeys(ids, list())
968 if self._all_columns.get('user_id'):
969 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
970 if not obj.user_id or not obj.user_id.partner_id:
972 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)
975 def message_get_partner_info_from_emails(self, cr, uid, emails, link_mail=False, context=None, res_id=None):
976 """ Wrapper with weird order parameter because of 7.0 fix.
978 TDE TODO: remove me in 8.0 """
979 return self.message_find_partner_from_emails(cr, uid, res_id, emails, link_mail=link_mail, context=context)
981 def message_find_partner_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
982 """ Convert a list of emails into a list partner_ids and a list
983 new_partner_ids. The return value is non conventional because
984 it is meant to be used by the mail widget.
986 :return dict: partner_ids and new_partner_ids
988 TDE TODO: merge me with other partner finding methods in 8.0 """
989 mail_message_obj = self.pool.get('mail.message')
990 partner_obj = self.pool.get('res.partner')
992 if id and self._name != 'mail.thread':
993 obj = self.browse(cr, SUPERUSER_ID, id, context=context)
997 partner_info = {'full_name': email, 'partner_id': False}
998 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
1001 email_address = m.group(3)
1002 # first try: check in document's followers
1004 for follower in obj.message_follower_ids:
1005 if follower.email == email_address:
1006 partner_info['partner_id'] = follower.id
1007 # second try: check in partners
1008 if not partner_info.get('partner_id'):
1009 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
1011 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address)], limit=1, context=context)
1013 partner_info['partner_id'] = ids[0]
1014 result.append(partner_info)
1016 # link mail with this from mail to the new partner id
1017 if link_mail and partner_info['partner_id']:
1018 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1020 ('email_from', '=', email),
1021 ('email_from', 'ilike', '<%s>' % email),
1022 ('author_id', '=', False)
1025 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1028 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1029 subtype=None, parent_id=False, attachments=None, context=None,
1030 content_subtype='html', **kwargs):
1031 """ Post a new message in an existing thread, returning the new
1034 :param int thread_id: thread ID to post into, or list with one ID;
1035 if False/0, mail.message model will also be set as False
1036 :param str body: body of the message, usually raw HTML that will
1038 :param str type: see mail_message.type field
1039 :param str content_subtype:: if plaintext: convert body into html
1040 :param int parent_id: handle reply to a previous message by adding the
1041 parent partners to the message in case of private discussion
1042 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1043 ``(name,content)``, where content is NOT base64 encoded
1045 Extra keyword arguments will be used as default column values for the
1046 new mail.message record. Special cases:
1047 - attachment_ids: supposed not attached to any document; attach them
1048 to the related document. Should only be set by Chatter.
1049 :return int: ID of newly created mail.message
1053 if attachments is None:
1055 mail_message = self.pool.get('mail.message')
1056 ir_attachment = self.pool.get('ir.attachment')
1058 assert (not thread_id) or \
1059 isinstance(thread_id, (int, long)) or \
1060 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1061 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1062 if isinstance(thread_id, (list, tuple)):
1063 thread_id = thread_id[0]
1065 # if we're processing a message directly coming from the gateway, the destination model was
1066 # set in the context.
1069 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1070 if model != self._name:
1071 del context['thread_model']
1072 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)
1074 # 0: Parse email-from, try to find a better author_id based on document's followers for incoming emails
1075 email_from = kwargs.get('email_from')
1076 if email_from and thread_id and type == 'email' and kwargs.get('author_id'):
1077 email_list = tools.email_split(email_from)
1078 doc = self.browse(cr, uid, thread_id, context=context)
1079 if email_list and doc:
1080 author_ids = self.pool.get('res.partner').search(cr, uid, [
1081 ('email', 'ilike', email_list[0]),
1082 ('id', 'in', [f.id for f in doc.message_follower_ids])
1083 ], limit=1, context=context)
1085 kwargs['author_id'] = author_ids[0]
1086 author_id = kwargs.get('author_id')
1087 if author_id is None: # keep False values
1088 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1090 # 1: Handle content subtype: if plaintext, converto into HTML
1091 if content_subtype == 'plaintext':
1092 body = tools.plaintext2html(body)
1094 # 2: Private message: add recipients (recipients and author of parent message) - current author
1095 # + legacy-code management (! we manage only 4 and 6 commands)
1097 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1098 for partner_id in kwargs_partner_ids:
1099 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1100 partner_ids.add(partner_id[1])
1101 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1102 partner_ids |= set(partner_id[2])
1103 elif isinstance(partner_id, (int, long)):
1104 partner_ids.add(partner_id)
1106 pass # we do not manage anything else
1107 if parent_id and not model:
1108 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1109 private_followers = set([partner.id for partner in parent_message.partner_ids])
1110 if parent_message.author_id:
1111 private_followers.add(parent_message.author_id.id)
1112 private_followers -= set([author_id])
1113 partner_ids |= private_followers
1116 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1117 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1119 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1120 ('res_model', '=', 'mail.compose.message'),
1121 ('create_uid', '=', uid),
1122 ('id', 'in', attachment_ids)], context=context)
1123 if filtered_attachment_ids:
1124 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1125 attachment_ids = [(4, id) for id in attachment_ids]
1126 # Handle attachments parameter, that is a dictionary of attachments
1127 for name, content in attachments:
1128 if isinstance(content, unicode):
1129 content = content.encode('utf-8')
1132 'datas': base64.b64encode(str(content)),
1133 'datas_fname': name,
1134 'description': name,
1136 'res_id': thread_id,
1138 attachment_ids.append((0, 0, data_attach))
1140 # 4: mail.message.subtype
1143 if '.' not in subtype:
1144 subtype = 'mail.%s' % subtype
1145 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1146 subtype_id = ref and ref[1] or False
1148 # automatically subscribe recipients if asked to
1149 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1150 partner_to_subscribe = partner_ids
1151 if context.get('mail_post_autofollow_partner_ids'):
1152 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1153 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1155 # _mail_flat_thread: automatically set free messages to the first posted message
1156 if self._mail_flat_thread and not parent_id and thread_id:
1157 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1158 parent_id = message_ids and message_ids[0] or False
1159 # 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
1161 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1162 # avoid loops when finding ancestors
1165 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1166 while (message.parent_id and message.parent_id.id not in processed_list):
1167 processed_list.append(message.parent_id.id)
1168 message = message.parent_id
1169 parent_id = message.id
1173 'author_id': author_id,
1175 'res_id': thread_id or False,
1177 'subject': subject or False,
1179 'parent_id': parent_id,
1180 'attachment_ids': attachment_ids,
1181 'subtype_id': subtype_id,
1182 'partner_ids': [(4, pid) for pid in partner_ids],
1185 # Avoid warnings about non-existing fields
1186 for x in ('from', 'to', 'cc'):
1189 # Create and auto subscribe the author
1190 msg_id = mail_message.create(cr, uid, values, context=context)
1191 message = mail_message.browse(cr, uid, msg_id, context=context)
1192 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1193 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1196 #------------------------------------------------------
1197 # Compatibility methods: do not use
1198 # TDE TODO: remove me in 8.0
1199 #------------------------------------------------------
1201 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
1202 return {'partner_ids': [], 'new_partner_ids': []}
1204 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
1205 attachment_ids=None, content_subtype='plaintext',
1206 context=None, **kwargs):
1207 return self.message_post(cr, uid, thread_id, body=body, parent_id=parent_id,
1208 attachment_ids=attachment_ids, content_subtype=content_subtype,
1209 context=context, **kwargs)
1211 #------------------------------------------------------
1213 #------------------------------------------------------
1215 def message_get_subscription_data(self, cr, uid, ids, context=None):
1216 """ Wrapper to get subtypes data. """
1217 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1219 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1220 """ Wrapper on message_subscribe, using users. If user_ids is not
1221 provided, subscribe uid instead. """
1222 if user_ids is None:
1224 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1225 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1227 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1228 """ Add partners to the records followers. """
1231 # not necessary for computation, but saves an access right check
1235 mail_followers_obj = self.pool.get('mail.followers')
1236 subtype_obj = self.pool.get('mail.message.subtype')
1238 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1239 if set(partner_ids) == set([user_pid]):
1241 self.check_access_rights(cr, uid, 'read')
1242 if context.get('operation', '') == 'create':
1243 self.check_access_rule(cr, uid, ids, 'create')
1245 self.check_access_rule(cr, uid, ids, 'read')
1246 except (osv.except_osv, orm.except_orm):
1249 self.check_access_rights(cr, uid, 'write')
1250 self.check_access_rule(cr, uid, ids, 'write')
1252 existing_pids_dict = {}
1253 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1254 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1255 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1257 # subtype_ids specified: update already subscribed partners
1258 if subtype_ids and fol_ids:
1259 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1260 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1261 if subtype_ids is None:
1262 subtype_ids = subtype_obj.search(
1264 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1267 existing_pids = existing_pids_dict.get(id, set())
1268 new_pids = set(partner_ids) - existing_pids
1270 # subscribe new followers
1271 for new_pid in new_pids:
1272 mail_followers_obj.create(
1274 'res_model': self._name,
1276 'partner_id': new_pid,
1277 'subtype_ids': [(6, 0, subtype_ids)],
1282 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1283 """ Wrapper on message_subscribe, using users. If user_ids is not
1284 provided, unsubscribe uid instead. """
1285 if user_ids is None:
1287 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1288 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1290 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1291 """ Remove partners from the records followers. """
1292 # not necessary for computation, but saves an access right check
1295 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1296 if set(partner_ids) == set([user_pid]):
1297 self.check_access_rights(cr, uid, 'read')
1298 self.check_access_rule(cr, uid, ids, 'read')
1300 self.check_access_rights(cr, uid, 'write')
1301 self.check_access_rule(cr, uid, ids, 'write')
1302 fol_obj = self.pool['mail.followers']
1303 fol_ids = fol_obj.search(
1305 ('res_model', '=', self._name),
1306 ('res_id', 'in', ids),
1307 ('partner_id', 'in', partner_ids)
1309 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1311 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1312 """ Returns the list of relational fields linking to res.users that should
1313 trigger an auto subscribe. The default list checks for the fields
1315 - linking to res.users
1316 - with track_visibility set
1317 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1318 project, issue, recruitment, sale.
1319 Override this method if a custom behavior is needed about fields
1320 that automatically subscribe users.
1323 for name, column_info in self._all_columns.items():
1324 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':
1325 user_field_lst.append(name)
1326 return user_field_lst
1328 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1329 """ Handle auto subscription. Two methods for auto subscription exist:
1331 - tracked res.users relational fields, such as user_id fields. Those fields
1332 must be relation fields toward a res.users record, and must have the
1333 track_visilibity attribute set.
1334 - using subtypes parent relationship: check if the current model being
1335 modified has an header record (such as a project for tasks) whose followers
1336 can be added as followers of the current records. Example of structure
1337 with project and task:
1339 - st_project_1.parent_id = st_task_1
1340 - st_project_1.res_model = 'project.project'
1341 - st_project_1.relation_field = 'project_id'
1342 - st_task_1.model = 'project.task'
1344 :param list updated_fields: list of updated fields to track
1345 :param dict values: updated values; if None, the first record will be browsed
1346 to get the values. Added after releasing 7.0, therefore
1347 not merged with updated_fields argumment.
1349 subtype_obj = self.pool.get('mail.message.subtype')
1350 follower_obj = self.pool.get('mail.followers')
1351 new_followers = dict()
1353 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1354 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1356 # fetch header subtypes
1357 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1358 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1360 # if no change in tracked field or no change in tracked relational field: quit
1361 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1362 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1365 # legacy behavior: if values is not given, compute the values by browsing
1366 # @TDENOTE: remove me in 8.0
1368 record = self.browse(cr, uid, ids[0], context=context)
1369 for updated_field in updated_fields:
1370 field_value = getattr(record, updated_field)
1371 if isinstance(field_value, browse_record):
1372 field_value = field_value.id
1373 elif isinstance(field_value, browse_null):
1375 values[updated_field] = field_value
1377 # find followers of headers, update structure for new followers
1379 for subtype in subtypes:
1380 if subtype.relation_field and values.get(subtype.relation_field):
1381 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1383 header_domain = ['|'] * (len(headers) - 1)
1384 for header in headers:
1385 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1386 header_follower_ids = follower_obj.search(
1391 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1392 for subtype in header_follower.subtype_ids:
1393 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1394 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1395 elif subtype.res_model is False:
1396 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1398 # add followers coming from res.users relational fields that are tracked
1399 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1400 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1401 for partner_id in user_pids:
1402 new_followers.setdefault(partner_id, None)
1404 for pid, subtypes in new_followers.items():
1405 subtypes = list(subtypes) if subtypes is not None else None
1406 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1408 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1410 for record_id in ids:
1411 message_obj = self.pool.get('mail.message')
1412 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1413 ('model', '=', self._name),
1414 ('res_id', '=', record_id),
1415 ('type', '=', 'email')], limit=1, context=context)
1417 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1418 ('model', '=', self._name),
1419 ('res_id', '=', record_id)], limit=1, context=context)
1421 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1425 #------------------------------------------------------
1427 #------------------------------------------------------
1429 def message_mark_as_unread(self, cr, uid, ids, context=None):
1430 """ Set as unread. """
1431 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1433 UPDATE mail_notification SET
1436 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1438 ''', (ids, self._name, partner_id))
1441 def message_mark_as_read(self, cr, uid, ids, context=None):
1442 """ Set as read. """
1443 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1445 UPDATE mail_notification SET
1448 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1450 ''', (ids, self._name, partner_id))
1453 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: