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 ##############################################################################
31 from email.message import Message
33 from openerp import tools
34 from openerp import SUPERUSER_ID
35 from openerp.addons.mail.mail_message import decode
36 from openerp.osv import fields, osv, orm
37 from openerp.osv.orm import browse_record, browse_null
38 from openerp.tools.safe_eval import safe_eval as eval
39 from openerp.tools.translate import _
41 _logger = logging.getLogger(__name__)
44 def decode_header(message, header, separator=' '):
45 return separator.join(map(decode, filter(None, message.get_all(header, []))))
48 class mail_thread(osv.AbstractModel):
49 ''' mail_thread model is meant to be inherited by any model that needs to
50 act as a discussion topic on which messages can be attached. Public
51 methods are prefixed with ``message_`` in order to avoid name
52 collisions with methods of the models that will inherit from this class.
54 ``mail.thread`` defines fields used to handle and display the
55 communication history. ``mail.thread`` also manages followers of
56 inheriting classes. All features and expected behavior are managed
57 by mail.thread. Widgets has been designed for the 7.0 and following
60 Inheriting classes are not required to implement any method, as the
61 default implementation will work for any model. However it is common
62 to override at least the ``message_new`` and ``message_update``
63 methods (calling ``super``) to add model-specific behavior at
64 creation and update of a thread when processing incoming emails.
67 - _mail_flat_thread: if set to True, all messages without parent_id
68 are automatically attached to the first message posted on the
69 ressource. If set to False, the display of Chatter is done using
70 threads, and no parent_id is automatically set.
73 _description = 'Email Thread'
74 _mail_flat_thread = True
76 # Automatic logging system if mail installed
79 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
80 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
87 # :param string field: field name
88 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
89 # :param obj: is a browse_record
90 # :param function lambda: returns whether the tracking should record using this subtype
93 def _get_message_data(self, cr, uid, ids, name, args, context=None):
95 - message_unread: has uid unread message for the document
96 - message_summary: html snippet summarizing the Chatter for kanban views """
97 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
98 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
100 # search for unread messages, directly in SQL to improve performances
101 cr.execute(""" SELECT m.res_id FROM mail_message m
102 RIGHT JOIN mail_notification n
103 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
104 WHERE m.model = %s AND m.res_id in %s""",
105 (user_pid, self._name, tuple(ids),))
106 for result in cr.fetchall():
107 res[result[0]]['message_unread'] = True
108 res[result[0]]['message_unread_count'] += 1
111 if res[id]['message_unread_count']:
112 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
113 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"))
116 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
118 - message_subtype_data: data about document subtypes: which are
119 available, which are followed if any """
120 res = dict((id, dict(message_subtype_data='')) for id in ids)
121 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
123 # find current model subtypes, add them to a dictionary
124 subtype_obj = self.pool.get('mail.message.subtype')
125 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
126 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))
128 res[id]['message_subtype_data'] = subtype_dict.copy()
130 # find the document followers, update the data
131 fol_obj = self.pool.get('mail.followers')
132 fol_ids = fol_obj.search(cr, uid, [
133 ('partner_id', '=', user_pid),
134 ('res_id', 'in', ids),
135 ('res_model', '=', self._name),
137 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
138 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
139 for subtype in fol.subtype_ids:
140 thread_subtype_dict[subtype.name]['followed'] = True
141 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
145 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
146 return [('message_ids.to_read', '=', True)]
148 def _get_followers(self, cr, uid, ids, name, arg, context=None):
149 fol_obj = self.pool.get('mail.followers')
150 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
151 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
152 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
153 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
154 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
155 if fol.partner_id.id == user_pid:
156 res[fol.res_id]['message_is_follower'] = True
159 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
162 partner_obj = self.pool.get('res.partner')
163 fol_obj = self.pool.get('mail.followers')
165 # read the old set of followers, and determine the new set of followers
166 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
167 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
170 for command in value or []:
171 if isinstance(command, (int, long)):
173 elif command[0] == 0:
174 new.add(partner_obj.create(cr, uid, command[2], context=context))
175 elif command[0] == 1:
176 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
178 elif command[0] == 2:
179 partner_obj.unlink(cr, uid, [command[1]], context=context)
180 new.discard(command[1])
181 elif command[0] == 3:
182 new.discard(command[1])
183 elif command[0] == 4:
185 elif command[0] == 5:
187 elif command[0] == 6:
188 new = set(command[2])
190 # remove partners that are no longer followers
191 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
192 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
193 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
196 for partner_id in new - old:
197 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
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',
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
243 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
245 # automatic logging unless asked not to (mainly for various testing purpose)
246 if not context.get('mail_create_nolog'):
247 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
249 # subscribe uid unless asked not to
250 if not context.get('mail_create_nosubscribe'):
251 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
252 # auto_subscribe: take values and defaults into account
253 create_values = dict(values)
254 for key, val in context.iteritems():
255 if key.startswith('default_'):
256 create_values[key[8:]] = val
257 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
260 track_ctx = dict(context)
261 if 'lang' not in track_ctx:
262 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
263 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
265 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
266 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
269 def write(self, cr, uid, ids, values, context=None):
272 if isinstance(ids, (int, long)):
275 # Track initial values of tracked fields
276 track_ctx = dict(context)
277 if 'lang' not in track_ctx:
278 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
279 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
281 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=track_ctx)
282 initial_values = dict((item['id'], item) for item in initial)
284 # Perform write, update followers
285 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
286 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
288 # Perform the tracking
290 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
293 def unlink(self, cr, uid, ids, context=None):
294 """ Override unlink to delete messages and followers. This cannot be
295 cascaded, because link is done through (res_model, res_id). """
296 msg_obj = self.pool.get('mail.message')
297 fol_obj = self.pool.get('mail.followers')
298 # delete messages and notifications
299 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
300 msg_obj.unlink(cr, uid, msg_ids, context=context)
302 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
304 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
305 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
308 def copy(self, cr, uid, id, default=None, context=None):
309 default = default or {}
310 default['message_ids'] = []
311 default['message_follower_ids'] = []
312 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
314 #------------------------------------------------------
315 # Automatically log tracked fields
316 #------------------------------------------------------
318 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
319 """ Return a structure of tracked fields for the current model.
320 :param list updated_fields: modified field names
321 :return list: a list of (field_name, column_info obj), containing
322 always tracked fields and modified on_change fields
325 for name, column_info in self._all_columns.items():
326 visibility = getattr(column_info.column, 'track_visibility', False)
327 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
331 return self.fields_get(cr, uid, lst, context=context)
333 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
335 def convert_for_display(value, col_info):
336 if not value and col_info['type'] == 'boolean':
340 if col_info['type'] == 'many2one':
342 if col_info['type'] == 'selection':
343 return dict(col_info['selection'])[value]
346 def format_message(message_description, tracked_values):
348 if message_description:
349 message = '<span>%s</span>' % message_description
350 for name, change in tracked_values.items():
351 message += '<div> • <b>%s</b>: ' % change.get('col_info')
352 if change.get('old_value'):
353 message += '%s → ' % change.get('old_value')
354 message += '%s</div>' % change.get('new_value')
357 if not tracked_fields:
360 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
361 initial = initial_values[record['id']]
365 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
366 for col_name, col_info in tracked_fields.items():
367 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
368 tracked_values[col_name] = dict(col_info=col_info['string'],
369 new_value=convert_for_display(record[col_name], col_info))
370 elif record[col_name] != initial[col_name]:
371 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
372 tracked_values[col_name] = dict(col_info=col_info['string'],
373 old_value=convert_for_display(initial[col_name], col_info),
374 new_value=convert_for_display(record[col_name], col_info))
375 if col_name in tracked_fields:
376 changes.append(col_name)
380 # find subtypes and post messages or log if no subtype found
382 for field, track_info in self._track.items():
383 if field not in changes:
385 for subtype, method in track_info.items():
386 if method(self, cr, uid, record, context):
387 subtypes.append(subtype)
390 for subtype in subtypes:
392 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
393 except ValueError, e:
394 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
396 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
397 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
400 message = format_message('', tracked_values)
401 self.message_post(cr, uid, record['id'], body=message, context=context)
404 #------------------------------------------------------
405 # mail.message wrappers and tools
406 #------------------------------------------------------
408 def _needaction_domain_get(self, cr, uid, context=None):
410 return [('message_unread', '=', True)]
413 def _garbage_collect_attachments(self, cr, uid, context=None):
414 """ Garbage collect lost mail attachments. Those are attachments
415 - linked to res_model 'mail.compose.message', the composer wizard
416 - with res_id 0, because they were created outside of an existing
417 wizard (typically user input through Chatter or reports
418 created on-the-fly by the templates)
419 - unused since at least one day (create_date and write_date)
421 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
422 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
423 ir_attachment_obj = self.pool.get('ir.attachment')
424 attach_ids = ir_attachment_obj.search(cr, uid, [
425 ('res_model', '=', 'mail.compose.message'),
427 ('create_date', '<', limit_date_str),
428 ('write_date', '<', limit_date_str),
430 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
433 #------------------------------------------------------
435 #------------------------------------------------------
437 def message_get_reply_to(self, cr, uid, ids, context=None):
438 if not self._inherits.get('mail.alias'):
439 return [False for id in ids]
440 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
441 if record.get('alias_domain') and record.get('alias_name')
443 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
445 #------------------------------------------------------
447 #------------------------------------------------------
449 def message_capable_models(self, cr, uid, context=None):
450 """ Used by the plugin addon, based for plugin_outlook and others. """
452 for model_name in self.pool.obj_list():
453 model = self.pool.get(model_name)
454 if hasattr(model, "message_process") and hasattr(model, "message_post"):
455 ret_dict[model_name] = model._description
458 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
459 """ Find partners related to some header fields of the message.
461 TDE TODO: merge me with other partner finding methods in 8.0 """
462 partner_obj = self.pool.get('res.partner')
464 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
465 for email_address in tools.email_split(s):
466 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
467 if not related_partners:
468 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address)], limit=1, context=context)
469 partner_ids += related_partners
472 def _message_find_user_id(self, cr, uid, message, context=None):
473 """ TDE TODO: check and maybe merge me with other user finding methods in 8.0 """
474 from_local_part = tools.email_split(decode(message.get('From')))[0]
475 # FP Note: canonification required, the minimu: .lower()
476 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
477 ('login', '=', from_local_part),
478 ('email', '=', from_local_part)], context=context)
479 return user_ids[0] if user_ids else uid
481 def message_route(self, cr, uid, message, model=None, thread_id=None,
482 custom_values=None, context=None):
483 """Attempt to figure out the correct target model, thread_id,
484 custom_values and user_id to use for an incoming message.
485 Multiple values may be returned, if a message had multiple
486 recipients matching existing mail.aliases, for example.
488 The following heuristics are used, in this order:
489 1. If the message replies to an existing thread_id, and
490 properly contains the thread model in the 'In-Reply-To'
491 header, use this model/thread_id pair, and ignore
492 custom_value (not needed as no creation will take place)
493 2. Look for a mail.alias entry matching the message
494 recipient, and use the corresponding model, thread_id,
495 custom_values and user_id.
496 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
498 4. If all the above fails, raise an exception.
500 :param string message: an email.message instance
501 :param string model: the fallback model to use if the message
502 does not match any of the currently configured mail aliases
503 (may be None if a matching alias is supposed to be present)
504 :type dict custom_values: optional dictionary of default field values
505 to pass to ``message_new`` if a new record needs to be created.
506 Ignored if the thread record already exists, and also if a
507 matching mail.alias was found (aliases define their own defaults)
508 :param int thread_id: optional ID of the record/thread from ``model``
509 to which this mail should be attached. Only used if the message
510 does not reply to an existing thread and does not match any mail alias.
511 :return: list of [model, thread_id, custom_values, user_id]
513 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
514 message_id = message.get('Message-Id')
515 email_from = decode_header(message, 'From')
516 email_to = decode_header(message, 'To')
517 references = decode_header(message, 'References')
518 in_reply_to = decode_header(message, 'In-Reply-To')
520 # 1. Verify if this is a reply to an existing thread
521 thread_references = references or in_reply_to
522 ref_match = thread_references and tools.reference_re.search(thread_references)
524 thread_id = int(ref_match.group(1))
525 model = ref_match.group(2) or model
526 model_pool = self.pool.get(model)
527 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
528 and hasattr(model_pool, 'message_update'):
529 _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',
530 email_from, email_to, message_id, model, thread_id, custom_values, uid)
531 return [(model, thread_id, custom_values, uid)]
533 # Verify whether this is a reply to a private message
535 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
537 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
538 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
539 email_from, email_to, message_id, message.id, custom_values, uid)
540 return [(message.model, message.res_id, custom_values, uid)]
542 # 2. Look for a matching mail.alias entry
543 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
544 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
546 ','.join([decode_header(message, 'Delivered-To'),
547 decode_header(message, 'To'),
548 decode_header(message, 'Cc'),
549 decode_header(message, 'Resent-To'),
550 decode_header(message, 'Resent-Cc')])
551 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
553 mail_alias = self.pool.get('mail.alias')
554 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
557 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
558 user_id = alias.alias_user_id.id
560 # TDE note: this could cause crashes, because no clue that the user
561 # that send the email has the right to create or modify a new document
562 # Fallback on user_id = uid
563 # Note: recognized partners will be added as followers anyway
564 # user_id = self._message_find_user_id(cr, uid, message, context=context)
566 _logger.info('No matching user_id for the alias %s', alias.alias_name)
567 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
568 eval(alias.alias_defaults), user_id))
569 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
570 email_from, email_to, message_id, routes)
573 # 3. Fallback to the provided parameters, if they work
574 model_pool = self.pool.get(model)
576 # Legacy: fallback to matching [ID] in the Subject
577 match = tools.res_re.search(decode_header(message, 'Subject'))
578 thread_id = match and match.group(1)
579 # Convert into int (bug spotted in 7.0 because of str)
581 thread_id = int(thread_id)
584 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
585 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
586 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
587 if thread_id and not model_pool.exists(cr, uid, thread_id):
588 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
589 thread_id, message_id)
591 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
592 email_from, email_to, message_id, model, thread_id, custom_values, uid)
593 return [(model, thread_id, custom_values, uid)]
595 def message_process(self, cr, uid, model, message, custom_values=None,
596 save_original=False, strip_attachments=False,
597 thread_id=None, context=None):
598 """ Process an incoming RFC2822 email message, relying on
599 ``mail.message.parse()`` for the parsing operation,
600 and ``message_route()`` to figure out the target model.
602 Once the target model is known, its ``message_new`` method
603 is called with the new message (if the thread record did not exist)
604 or its ``message_update`` method (if it did).
606 There is a special case where the target model is False: a reply
607 to a private message. In this case, we skip the message_new /
608 message_update step, to just post a new message using mail_thread
611 :param string model: the fallback model to use if the message
612 does not match any of the currently configured mail aliases
613 (may be None if a matching alias is supposed to be present)
614 :param message: source of the RFC2822 message
615 :type message: string or xmlrpclib.Binary
616 :type dict custom_values: optional dictionary of field values
617 to pass to ``message_new`` if a new record needs to be created.
618 Ignored if the thread record already exists, and also if a
619 matching mail.alias was found (aliases define their own defaults)
620 :param bool save_original: whether to keep a copy of the original
621 email source attached to the message after it is imported.
622 :param bool strip_attachments: whether to strip all attachments
623 before processing the message, in order to save some space.
624 :param int thread_id: optional ID of the record/thread from ``model``
625 to which this mail should be attached. When provided, this
626 overrides the automatic detection based on the message
632 # extract message bytes - we are forced to pass the message as binary because
633 # we don't know its encoding until we parse its headers and hence can't
634 # convert it to utf-8 for transport between the mailgate script and here.
635 if isinstance(message, xmlrpclib.Binary):
636 message = str(message.data)
637 # Warning: message_from_string doesn't always work correctly on unicode,
638 # we must use utf-8 strings here :-(
639 if isinstance(message, unicode):
640 message = message.encode('utf-8')
641 msg_txt = email.message_from_string(message)
643 # parse the message, verify we are not in a loop by checking message_id is not duplicated
644 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
645 if strip_attachments:
646 msg.pop('attachments', None)
647 if msg.get('message_id'): # should always be True as message_parse generate one if missing
648 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
649 ('message_id', '=', msg.get('message_id')),
652 _logger.info('Ignored mail from %s to %s with Message-Id %s:: found duplicated Message-Id during processing',
653 msg.get('from'), msg.get('to'), msg.get('message_id'))
656 # find possible routes for the message
657 routes = self.message_route(cr, uid, msg_txt, model,
658 thread_id, custom_values,
661 # postpone setting msg.partner_ids after message_post, to avoid double notifications
662 partner_ids = msg.pop('partner_ids', [])
665 for model, thread_id, custom_values, user_id in routes:
666 if self._name == 'mail.thread':
667 context.update({'thread_model': model})
669 model_pool = self.pool.get(model)
670 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
671 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
672 (msg['message_id'], model)
674 # disabled subscriptions during message_new/update to avoid having the system user running the
675 # email gateway become a follower of all inbound messages
676 nosub_ctx = dict(context, mail_create_nosubscribe=True)
677 if thread_id and hasattr(model_pool, 'message_update'):
678 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
680 nosub_ctx = dict(nosub_ctx, mail_create_nolog=True)
681 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
683 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
684 model_pool = self.pool.get('mail.thread')
685 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
688 # postponed after message_post, because this is an external message and we don't want to create
689 # duplicate emails due to notifications
690 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
694 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
695 """Called by ``message_process`` when a new message is received
696 for a given thread model, if the message did not belong to
698 The default behavior is to create a new record of the corresponding
699 model (based on some very basic info extracted from the message).
700 Additional behavior may be implemented by overriding this method.
702 :param dict msg_dict: a map containing the email details and
703 attachments. See ``message_process`` and
704 ``mail.message.parse`` for details.
705 :param dict custom_values: optional dictionary of additional
706 field values to pass to create()
707 when creating the new thread record.
708 Be careful, these values may override
709 any other values coming from the message.
710 :param dict context: if a ``thread_model`` value is present
711 in the context, its value will be used
712 to determine the model of the record
713 to create (instead of the current model).
715 :return: the id of the newly created thread object
720 if isinstance(custom_values, dict):
721 data = custom_values.copy()
722 model = context.get('thread_model') or self._name
723 model_pool = self.pool.get(model)
724 fields = model_pool.fields_get(cr, uid, context=context)
725 if 'name' in fields and not data.get('name'):
726 data['name'] = msg_dict.get('subject', '')
727 res_id = model_pool.create(cr, uid, data, context=context)
730 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
731 """Called by ``message_process`` when a new message is received
732 for an existing thread. The default behavior is to update the record
733 with update_vals taken from the incoming email.
734 Additional behavior may be implemented by overriding this
736 :param dict msg_dict: a map containing the email details and
737 attachments. See ``message_process`` and
738 ``mail.message.parse()`` for details.
739 :param dict update_vals: a dict containing values to update records
740 given their ids; if the dict is None or is
741 void, no write operation is performed.
744 self.write(cr, uid, ids, update_vals, context=context)
747 def _message_extract_payload(self, message, save_original=False):
748 """Extract body as HTML and attachments from the mail message"""
752 attachments.append(('original_email.eml', message.as_string()))
753 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
754 encoding = message.get_content_charset()
755 body = message.get_payload(decode=True)
756 body = tools.ustr(body, encoding, errors='replace')
757 if message.get_content_type() == 'text/plain':
758 # text/plain -> <pre/>
759 body = tools.append_content_to_html(u'', body, preserve=True)
762 for part in message.walk():
763 if part.get_content_type() == 'multipart/alternative':
765 if part.get_content_maintype() == 'multipart':
766 continue # skip container
767 filename = part.get_filename() # None if normal part
768 encoding = part.get_content_charset() # None if attachment
769 # 1) Explicit Attachments -> attachments
770 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
771 attachments.append((decode(filename) or 'attachment', part.get_payload(decode=True)))
773 # 2) text/plain -> <pre/>
774 if part.get_content_type() == 'text/plain' and (not alternative or not body):
775 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
776 encoding, errors='replace'), preserve=True)
777 # 3) text/html -> raw
778 elif part.get_content_type() == 'text/html':
779 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
783 body = tools.append_content_to_html(body, html, plaintext=False)
784 # 4) Anything else -> attachment
786 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
787 return body, attachments
789 def message_parse(self, cr, uid, message, save_original=False, context=None):
790 """Parses a string or email.message.Message representing an
791 RFC-2822 email, and returns a generic dict holding the
794 :param message: the message to parse
795 :type message: email.message.Message | string | unicode
796 :param bool save_original: whether the returned dict
797 should include an ``original`` attachment containing
798 the source of the message
800 :return: A dict with the following structure, where each
801 field may not be present if missing in original
804 { 'message_id': msg_id,
809 'body': unified_body,
810 'attachments': [('file1', 'bytes'),
818 if not isinstance(message, Message):
819 if isinstance(message, unicode):
820 # Warning: message_from_string doesn't always work correctly on unicode,
821 # we must use utf-8 strings here :-(
822 message = message.encode('utf-8')
823 message = email.message_from_string(message)
825 message_id = message['message-id']
827 # Very unusual situation, be we should be fault-tolerant here
828 message_id = "<%s@localhost>" % time.time()
829 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
830 msg_dict['message_id'] = message_id
832 if message.get('Subject'):
833 msg_dict['subject'] = decode(message.get('Subject'))
835 # Envelope fields not stored in mail.message but made available for message_new()
836 msg_dict['from'] = decode(message.get('from'))
837 msg_dict['to'] = decode(message.get('to'))
838 msg_dict['cc'] = decode(message.get('cc'))
840 if message.get('From'):
841 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
843 msg_dict['author_id'] = author_ids[0]
844 msg_dict['email_from'] = decode(message.get('from'))
845 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
846 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
848 if message.get('Date'):
850 date_hdr = decode(message.get('Date'))
851 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
852 if parsed_date.utcoffset() is None:
853 # naive datetime, so we arbitrarily decide to make it
854 # UTC, there's no better choice. Should not happen,
855 # as RFC2822 requires timezone offset in Date headers.
856 stored_date = parsed_date.replace(tzinfo=pytz.utc)
858 stored_date = parsed_date.astimezone(tz=pytz.utc)
860 _logger.warning('Failed to parse Date header %r in incoming mail '
861 'with message-id %r, assuming current date/time.',
862 message.get('Date'), message_id)
863 stored_date = datetime.datetime.now()
864 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
866 if message.get('In-Reply-To'):
867 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
869 msg_dict['parent_id'] = parent_ids[0]
871 if message.get('References') and 'parent_id' not in msg_dict:
872 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
873 [x.strip() for x in decode(message['References']).split()])])
875 msg_dict['parent_id'] = parent_ids[0]
877 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
880 #------------------------------------------------------
882 #------------------------------------------------------
884 def log(self, cr, uid, id, message, secondary=False, context=None):
885 _logger.warning("log() is deprecated. As this module inherit from "\
886 "mail.thread, the message will be managed by this "\
887 "module instead of by the res.log mechanism. Please "\
888 "use mail_thread.message_post() instead of the "\
889 "now deprecated res.log.")
890 self.message_post(cr, uid, [id], message, context=context)
892 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
893 """ Called by message_get_suggested_recipients, to add a suggested
894 recipient in the result dictionary. The form is :
895 partner_id, partner_name<partner_email> or partner_name, reason """
896 if email and not partner:
897 # get partner info from email
898 partner_info = self.message_get_partner_info_from_emails(cr, uid, [email], context=context, res_id=obj.id)
899 if partner_info and partner_info[0].get('partner_id'):
900 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info[0]['partner_id']], context=context)[0]
901 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
903 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
905 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
907 if partner and partner.email: # complete profile: id, name <email>
908 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
909 elif partner: # incomplete profile: id, name
910 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
911 else: # unknown partner, we are probably managing an email address
912 result[obj.id].append((False, email, reason))
915 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
916 """ Returns suggested recipients for ids. Those are a list of
917 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
918 result = dict.fromkeys(ids, list())
919 if self._all_columns.get('user_id'):
920 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
921 if not obj.user_id or not obj.user_id.partner_id:
923 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)
926 def message_get_partner_info_from_emails(self, cr, uid, emails, link_mail=False, context=None, res_id=None):
927 """ Wrapper with weird order parameter because of 7.0 fix.
929 TDE TODO: remove me in 8.0 """
930 return self.message_find_partner_from_emails(cr, uid, res_id, emails, link_mail=link_mail, context=context)
932 def message_find_partner_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
933 """ Convert a list of emails into a list partner_ids and a list
934 new_partner_ids. The return value is non conventional because
935 it is meant to be used by the mail widget.
937 :return dict: partner_ids and new_partner_ids
939 TDE TODO: merge me with other partner finding methods in 8.0 """
940 mail_message_obj = self.pool.get('mail.message')
941 partner_obj = self.pool.get('res.partner')
943 if id and self._name != 'mail.thread':
944 obj = self.browse(cr, SUPERUSER_ID, id, context=context)
948 partner_info = {'full_name': email, 'partner_id': False}
949 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
952 email_address = m.group(3)
953 # first try: check in document's followers
955 for follower in obj.message_follower_ids:
956 if follower.email == email_address:
957 partner_info['partner_id'] = follower.id
958 # second try: check in partners
959 if not partner_info.get('partner_id'):
960 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
962 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address)], limit=1, context=context)
964 partner_info['partner_id'] = ids[0]
965 result.append(partner_info)
967 # link mail with this from mail to the new partner id
968 if link_mail and partner_info['partner_id']:
969 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
971 ('email_from', '=', email),
972 ('email_from', 'ilike', '<%s>' % email),
973 ('author_id', '=', False)
976 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
979 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
980 subtype=None, parent_id=False, attachments=None, context=None,
981 content_subtype='html', **kwargs):
982 """ Post a new message in an existing thread, returning the new
985 :param int thread_id: thread ID to post into, or list with one ID;
986 if False/0, mail.message model will also be set as False
987 :param str body: body of the message, usually raw HTML that will
989 :param str type: see mail_message.type field
990 :param str content_subtype:: if plaintext: convert body into html
991 :param int parent_id: handle reply to a previous message by adding the
992 parent partners to the message in case of private discussion
993 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
994 ``(name,content)``, where content is NOT base64 encoded
996 Extra keyword arguments will be used as default column values for the
997 new mail.message record. Special cases:
998 - attachment_ids: supposed not attached to any document; attach them
999 to the related document. Should only be set by Chatter.
1000 :return int: ID of newly created mail.message
1004 if attachments is None:
1006 mail_message = self.pool.get('mail.message')
1007 ir_attachment = self.pool.get('ir.attachment')
1009 assert (not thread_id) or \
1010 isinstance(thread_id, (int, long)) or \
1011 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1012 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1013 if isinstance(thread_id, (list, tuple)):
1014 thread_id = thread_id[0]
1016 # if we're processing a message directly coming from the gateway, the destination model was
1017 # set in the context.
1020 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1021 if model != self._name:
1022 del context['thread_model']
1023 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)
1025 # 0: Parse email-from, try to find a better author_id based on document's followers for incoming emails
1026 email_from = kwargs.get('email_from')
1027 if email_from and thread_id and type == 'email' and kwargs.get('author_id'):
1028 email_list = tools.email_split(email_from)
1029 doc = self.browse(cr, uid, thread_id, context=context)
1030 if email_list and doc:
1031 author_ids = self.pool.get('res.partner').search(cr, uid, [
1032 ('email', 'ilike', email_list[0]),
1033 ('id', 'in', [f.id for f in doc.message_follower_ids])
1034 ], limit=1, context=context)
1036 kwargs['author_id'] = author_ids[0]
1037 author_id = kwargs.get('author_id')
1038 if author_id is None: # keep False values
1039 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1041 # 1: Handle content subtype: if plaintext, converto into HTML
1042 if content_subtype == 'plaintext':
1043 body = tools.plaintext2html(body)
1045 # 2: Private message: add recipients (recipients and author of parent message) - current author
1046 # + legacy-code management (! we manage only 4 and 6 commands)
1048 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1049 for partner_id in kwargs_partner_ids:
1050 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1051 partner_ids.add(partner_id[1])
1052 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1053 partner_ids |= set(partner_id[2])
1054 elif isinstance(partner_id, (int, long)):
1055 partner_ids.add(partner_id)
1057 pass # we do not manage anything else
1058 if parent_id and not model:
1059 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1060 private_followers = set([partner.id for partner in parent_message.partner_ids])
1061 if parent_message.author_id:
1062 private_followers.add(parent_message.author_id.id)
1063 private_followers -= set([author_id])
1064 partner_ids |= private_followers
1067 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1068 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1070 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1071 ('res_model', '=', 'mail.compose.message'),
1072 ('create_uid', '=', uid),
1073 ('id', 'in', attachment_ids)], context=context)
1074 if filtered_attachment_ids:
1075 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1076 attachment_ids = [(4, id) for id in attachment_ids]
1077 # Handle attachments parameter, that is a dictionary of attachments
1078 for name, content in attachments:
1079 if isinstance(content, unicode):
1080 content = content.encode('utf-8')
1083 'datas': base64.b64encode(str(content)),
1084 'datas_fname': name,
1085 'description': name,
1087 'res_id': thread_id,
1089 attachment_ids.append((0, 0, data_attach))
1091 # 4: mail.message.subtype
1094 if '.' not in subtype:
1095 subtype = 'mail.%s' % subtype
1096 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1097 subtype_id = ref and ref[1] or False
1099 # automatically subscribe recipients if asked to
1100 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1101 partner_to_subscribe = partner_ids
1102 if context.get('mail_post_autofollow_partner_ids'):
1103 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1104 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1106 # _mail_flat_thread: automatically set free messages to the first posted message
1107 if self._mail_flat_thread and not parent_id and thread_id:
1108 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1109 parent_id = message_ids and message_ids[0] or False
1110 # 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
1112 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1113 # avoid loops when finding ancestors
1116 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1117 while (message.parent_id and message.parent_id.id not in processed_list):
1118 processed_list.append(message.parent_id.id)
1119 message = message.parent_id
1120 parent_id = message.id
1124 'author_id': author_id,
1126 'res_id': thread_id or False,
1128 'subject': subject or False,
1130 'parent_id': parent_id,
1131 'attachment_ids': attachment_ids,
1132 'subtype_id': subtype_id,
1133 'partner_ids': [(4, pid) for pid in partner_ids],
1136 # Avoid warnings about non-existing fields
1137 for x in ('from', 'to', 'cc'):
1140 # Create and auto subscribe the author
1141 msg_id = mail_message.create(cr, uid, values, context=context)
1142 message = mail_message.browse(cr, uid, msg_id, context=context)
1143 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1144 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1147 #------------------------------------------------------
1148 # Compatibility methods: do not use
1149 # TDE TODO: remove me in 8.0
1150 #------------------------------------------------------
1152 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
1153 return {'partner_ids': [], 'new_partner_ids': []}
1155 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
1156 attachment_ids=None, content_subtype='plaintext',
1157 context=None, **kwargs):
1158 return self.message_post(cr, uid, thread_id, body=body, parent_id=parent_id,
1159 attachment_ids=attachment_ids, content_subtype=content_subtype,
1160 context=context, **kwargs)
1162 #------------------------------------------------------
1164 #------------------------------------------------------
1166 def message_get_subscription_data(self, cr, uid, ids, context=None):
1167 """ Wrapper to get subtypes data. """
1168 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1170 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1171 """ Wrapper on message_subscribe, using users. If user_ids is not
1172 provided, subscribe uid instead. """
1173 if user_ids is None:
1175 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1176 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1178 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1179 """ Add partners to the records followers. """
1180 mail_followers_obj = self.pool.get('mail.followers')
1181 subtype_obj = self.pool.get('mail.message.subtype')
1183 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1184 if set(partner_ids) == set([user_pid]):
1186 self.check_access_rights(cr, uid, 'read')
1187 except (osv.except_osv, orm.except_orm):
1190 self.check_access_rights(cr, uid, 'write')
1192 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1193 existing_pids = set([f.id for f in record.message_follower_ids
1194 if f.id in partner_ids])
1195 new_pids = set(partner_ids) - existing_pids
1197 # subtype_ids specified: update already subscribed partners
1198 if subtype_ids and existing_pids:
1199 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1200 ('res_model', '=', self._name),
1201 ('res_id', '=', record.id),
1202 ('partner_id', 'in', list(existing_pids)),
1204 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1205 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1206 elif subtype_ids is None:
1207 subtype_ids = subtype_obj.search(cr, uid, [
1208 ('default', '=', True),
1210 ('res_model', '=', self._name),
1211 ('res_model', '=', False)
1213 # subscribe new followers
1214 for new_pid in new_pids:
1215 mail_followers_obj.create(cr, SUPERUSER_ID, {
1216 'res_model': self._name,
1217 'res_id': record.id,
1218 'partner_id': new_pid,
1219 'subtype_ids': [(6, 0, subtype_ids)],
1224 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1225 """ Wrapper on message_subscribe, using users. If user_ids is not
1226 provided, unsubscribe uid instead. """
1227 if user_ids is None:
1229 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1230 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1232 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1233 """ Remove partners from the records followers. """
1234 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1235 if set(partner_ids) == set([user_pid]):
1236 self.check_access_rights(cr, uid, 'read')
1238 self.check_access_rights(cr, uid, 'write')
1239 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1241 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1242 """ Returns the list of relational fields linking to res.users that should
1243 trigger an auto subscribe. The default list checks for the fields
1245 - linking to res.users
1246 - with track_visibility set
1247 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1248 project, issue, recruitment, sale.
1249 Override this method if a custom behavior is needed about fields
1250 that automatically subscribe users.
1253 for name, column_info in self._all_columns.items():
1254 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':
1255 user_field_lst.append(name)
1256 return user_field_lst
1258 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1259 """ Handle auto subscription. Two methods for auto subscription exist:
1261 - tracked res.users relational fields, such as user_id fields. Those fields
1262 must be relation fields toward a res.users record, and must have the
1263 track_visilibity attribute set.
1264 - using subtypes parent relationship: check if the current model being
1265 modified has an header record (such as a project for tasks) whose followers
1266 can be added as followers of the current records. Example of structure
1267 with project and task:
1269 - st_project_1.parent_id = st_task_1
1270 - st_project_1.res_model = 'project.project'
1271 - st_project_1.relation_field = 'project_id'
1272 - st_task_1.model = 'project.task'
1274 :param list updated_fields: list of updated fields to track
1275 :param dict values: updated values; if None, the first record will be browsed
1276 to get the values. Added after releasing 7.0, therefore
1277 not merged with updated_fields argumment.
1279 subtype_obj = self.pool.get('mail.message.subtype')
1280 follower_obj = self.pool.get('mail.followers')
1281 new_followers = dict()
1283 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1284 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1286 # fetch header subtypes
1287 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1288 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1290 # if no change in tracked field or no change in tracked relational field: quit
1291 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1292 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1295 # legacy behavior: if values is not given, compute the values by browsing
1296 # @TDENOTE: remove me in 8.0
1298 record = self.browse(cr, uid, ids[0], context=context)
1299 for updated_field in updated_fields:
1300 field_value = getattr(record, updated_field)
1301 if isinstance(field_value, browse_record):
1302 field_value = field_value.id
1303 elif isinstance(field_value, browse_null):
1305 values[updated_field] = field_value
1307 # find followers of headers, update structure for new followers
1309 for subtype in subtypes:
1310 if subtype.relation_field and values.get(subtype.relation_field):
1311 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1313 header_domain = ['|'] * (len(headers) - 1)
1314 for header in headers:
1315 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1316 header_follower_ids = follower_obj.search(
1321 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1322 for subtype in header_follower.subtype_ids:
1323 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1324 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1325 elif subtype.res_model is False:
1326 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1328 # add followers coming from res.users relational fields that are tracked
1329 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1330 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1331 for partner_id in user_pids:
1332 new_followers.setdefault(partner_id, None)
1334 for pid, subtypes in new_followers.items():
1335 subtypes = list(subtypes) if subtypes is not None else None
1336 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1338 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1340 for record_id in ids:
1341 message_obj = self.pool.get('mail.message')
1342 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1343 ('model', '=', self._name),
1344 ('res_id', '=', record_id),
1345 ('type', '=', 'email')], limit=1, context=context)
1347 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1348 ('model', '=', self._name),
1349 ('res_id', '=', record_id)], limit=1, context=context)
1351 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1355 #------------------------------------------------------
1357 #------------------------------------------------------
1359 def message_mark_as_unread(self, cr, uid, ids, context=None):
1360 """ Set as unread. """
1361 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1363 UPDATE mail_notification SET
1366 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1368 ''', (ids, self._name, partner_id))
1371 def message_mark_as_read(self, cr, uid, ids, context=None):
1372 """ Set as read. """
1373 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1375 UPDATE mail_notification SET
1378 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1380 ''', (ids, self._name, partner_id))
1383 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: