[MERGE] Forward-port of latest 7.0 bugfixes, up to rev. 9929 revid:dle@openerp.com...
[odoo/odoo.git] / addons / mail / mail_thread.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
6 #
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
11 #
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
16 #
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/>
19 #
20 ##############################################################################
21
22 import base64
23 import datetime
24 import dateutil
25 import email
26 import logging
27 import pytz
28 import time
29 import xmlrpclib
30 from email.message import Message
31
32 from openerp import tools
33 from openerp import SUPERUSER_ID
34 from openerp.addons.mail.mail_message import decode
35 from openerp.osv import fields, osv, orm
36 from openerp.osv.orm import browse_record, browse_null
37 from openerp.tools.safe_eval import safe_eval as eval
38 from openerp.tools.translate import _
39
40 _logger = logging.getLogger(__name__)
41
42
43 def decode_header(message, header, separator=' '):
44     return separator.join(map(decode, filter(None, message.get_all(header, []))))
45
46
47 class mail_thread(osv.AbstractModel):
48     ''' mail_thread model is meant to be inherited by any model that needs to
49         act as a discussion topic on which messages can be attached. Public
50         methods are prefixed with ``message_`` in order to avoid name
51         collisions with methods of the models that will inherit from this class.
52
53         ``mail.thread`` defines fields used to handle and display the
54         communication history. ``mail.thread`` also manages followers of
55         inheriting classes. All features and expected behavior are managed
56         by mail.thread. Widgets has been designed for the 7.0 and following
57         versions of OpenERP.
58
59         Inheriting classes are not required to implement any method, as the
60         default implementation will work for any model. However it is common
61         to override at least the ``message_new`` and ``message_update``
62         methods (calling ``super``) to add model-specific behavior at
63         creation and update of a thread when processing incoming emails.
64
65         Options:
66             - _mail_flat_thread: if set to True, all messages without parent_id
67                 are automatically attached to the first message posted on the
68                 ressource. If set to False, the display of Chatter is done using
69                 threads, and no parent_id is automatically set.
70     '''
71     _name = 'mail.thread'
72     _description = 'Email Thread'
73     _mail_flat_thread = True
74     _mail_post_access = 'write'
75
76     # Automatic logging system if mail installed
77     # _track = {
78     #   'field': {
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,
81     #   },
82     #   'field2': {
83     #       ...
84     #   },
85     # }
86     # where
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
91     _track = {}
92
93     def get_empty_list_help(self, cr, uid, help, context=None):
94         """ Override of BaseModel.get_empty_list_help() to generate an help message
95             that adds alias information. """
96         model = context.get('empty_list_help_model')
97         res_id = context.get('empty_list_help_id')
98         ir_config_parameter = self.pool.get("ir.config_parameter")
99         catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
100         document_name = context.get('empty_list_help_document_name', _('document'))
101         alias = None
102
103         if catchall_domain and model and res_id:  # specific res_id -> find its alias (i.e. section_id specified)
104             object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
105             # check that the alias effectively creates new records
106             if object_id.alias_id and object_id.alias_id.alias_name and \
107                     object_id.alias_id.alias_model_id and \
108                     object_id.alias_id.alias_model_id.model == self._name and \
109                     object_id.alias_id.alias_force_thread_id == 0:
110                 alias = object_id.alias_id
111         elif catchall_domain and model:  # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
112             model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
113             alias_obj = self.pool.get('mail.alias')
114             alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id), ("alias_name", "!=", False), ('alias_force_thread_id', '=', 0)], context=context, order='id ASC')
115             if alias_ids and len(alias_ids) == 1:  # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
116                 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
117
118         if alias:
119             alias_email = alias.name_get()[0][1]
120             return _("""<p class='oe_view_nocontent_create'>
121                             Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
122                         </p>
123                         %(static_help)s"""
124                     ) % {
125                         'document': document_name,
126                         'email': alias_email,
127                         'static_help': help or ''
128                     }
129
130         if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
131             return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
132                         'document': document_name,
133                         'static_help': help or '',
134                     }
135
136         return help
137
138     def _get_message_data(self, cr, uid, ids, name, args, context=None):
139         """ Computes:
140             - message_unread: has uid unread message for the document
141             - message_summary: html snippet summarizing the Chatter for kanban views """
142         res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
143         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
144
145         # search for unread messages, directly in SQL to improve performances
146         cr.execute("""  SELECT m.res_id FROM mail_message m
147                         RIGHT JOIN mail_notification n
148                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
149                         WHERE m.model = %s AND m.res_id in %s""",
150                     (user_pid, self._name, tuple(ids),))
151         for result in cr.fetchall():
152             res[result[0]]['message_unread'] = True
153             res[result[0]]['message_unread_count'] += 1
154
155         for id in ids:
156             if res[id]['message_unread_count']:
157                 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
158                 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"))
159             res[id].pop('message_unread_count', None)
160         return res
161
162     def read_followers_data(self, cr, uid, follower_ids, context=None):
163         result = []
164         technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one')
165         for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
166             is_editable = uid in map(lambda x: x.id, technical_group.users)
167             is_uid = uid in map(lambda x: x.id, follower.user_ids)
168             data = (follower.id,
169                     follower.name,
170                     {'is_editable': is_editable, 'is_uid': is_uid},
171                     )
172             result.append(data)
173         return result
174
175     def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
176         """ Computes:
177             - message_subtype_data: data about document subtypes: which are
178                 available, which are followed if any """
179         res = dict((id, dict(message_subtype_data='')) for id in ids)
180         if user_pid is None:
181             user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
182
183         # find current model subtypes, add them to a dictionary
184         subtype_obj = self.pool.get('mail.message.subtype')
185         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
186         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))
187         for id in ids:
188             res[id]['message_subtype_data'] = subtype_dict.copy()
189
190         # find the document followers, update the data
191         fol_obj = self.pool.get('mail.followers')
192         fol_ids = fol_obj.search(cr, uid, [
193             ('partner_id', '=', user_pid),
194             ('res_id', 'in', ids),
195             ('res_model', '=', self._name),
196         ], context=context)
197         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
198             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
199             for subtype in fol.subtype_ids:
200                 thread_subtype_dict[subtype.name]['followed'] = True
201             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
202
203         return res
204
205     def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
206         return [('message_ids.to_read', '=', True)]
207
208     def _get_followers(self, cr, uid, ids, name, arg, context=None):
209         fol_obj = self.pool.get('mail.followers')
210         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
211         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
212         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
213         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
214             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
215             if fol.partner_id.id == user_pid:
216                 res[fol.res_id]['message_is_follower'] = True
217         return res
218
219     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
220         if not value:
221             return
222         partner_obj = self.pool.get('res.partner')
223         fol_obj = self.pool.get('mail.followers')
224
225         # read the old set of followers, and determine the new set of followers
226         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
227         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
228         new = set(old)
229
230         for command in value or []:
231             if isinstance(command, (int, long)):
232                 new.add(command)
233             elif command[0] == 0:
234                 new.add(partner_obj.create(cr, uid, command[2], context=context))
235             elif command[0] == 1:
236                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
237                 new.add(command[1])
238             elif command[0] == 2:
239                 partner_obj.unlink(cr, uid, [command[1]], context=context)
240                 new.discard(command[1])
241             elif command[0] == 3:
242                 new.discard(command[1])
243             elif command[0] == 4:
244                 new.add(command[1])
245             elif command[0] == 5:
246                 new.clear()
247             elif command[0] == 6:
248                 new = set(command[2])
249
250         # remove partners that are no longer followers
251         self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
252         # add new followers
253         self.message_subscribe(cr, uid, [id], list(new-old), context=context)
254
255     def _search_followers(self, cr, uid, obj, name, args, context):
256         """Search function for message_follower_ids
257
258         Do not use with operator 'not in'. Use instead message_is_followers
259         """
260         fol_obj = self.pool.get('mail.followers')
261         res = []
262         for field, operator, value in args:
263             assert field == name
264             # TOFIX make it work with not in
265             assert operator != "not in", "Do not search message_follower_ids with 'not in'"
266             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
267             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
268             res.append(('id', 'in', res_ids))
269         return res
270
271     def _search_is_follower(self, cr, uid, obj, name, args, context):
272         """Search function for message_is_follower"""
273         res = []
274         for field, operator, value in args:
275             assert field == name
276             partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
277             if (operator == '=' and value) or (operator == '!=' and not value):  # is a follower
278                 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
279             else:  # is not a follower or unknown domain
280                 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
281                 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
282             res.append(('id', 'in', res_ids))
283         return res
284
285     _columns = {
286         'message_is_follower': fields.function(_get_followers, type='boolean',
287             fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
288         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
289             fnct_search=_search_followers, type='many2many', priority=-10,
290             obj='res.partner', string='Followers', multi='_get_followers'),
291         'message_ids': fields.one2many('mail.message', 'res_id',
292             domain=lambda self: [('model', '=', self._name)],
293             auto_join=True,
294             string='Messages',
295             help="Messages and communication history"),
296         'message_unread': fields.function(_get_message_data,
297             fnct_search=_search_message_unread, multi="_get_message_data",
298             type='boolean', string='Unread Messages',
299             help="If checked new messages require your attention."),
300         'message_summary': fields.function(_get_message_data, method=True,
301             type='text', string='Summary', multi="_get_message_data",
302             help="Holds the Chatter summary (number of messages, ...). "\
303                  "This summary is directly in html format in order to "\
304                  "be inserted in kanban views."),
305     }
306
307     #------------------------------------------------------
308     # CRUD overrides for automatic subscription and logging
309     #------------------------------------------------------
310
311     def create(self, cr, uid, values, context=None):
312         """ Chatter override :
313             - subscribe uid
314             - subscribe followers of parent
315             - log a creation message
316         """
317         if context is None:
318             context = {}
319
320         # subscribe uid unless asked not to
321         if not context.get('mail_create_nosubscribe'):
322             pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
323             message_follower_ids = values.get('message_follower_ids') or []  # webclient can send None or False
324             message_follower_ids.append([4, pid])
325             values['message_follower_ids'] = message_follower_ids
326             # add operation to ignore access rule checking for subscription
327             context_operation = dict(context, operation='create')
328         else:
329             context_operation = context
330         thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
331
332         # automatic logging unless asked not to (mainly for various testing purpose)
333         if not context.get('mail_create_nolog'):
334             self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
335
336         # auto_subscribe: take values and defaults into account
337         create_values = dict(values)
338         for key, val in context.iteritems():
339             if key.startswith('default_'):
340                 create_values[key[8:]] = val
341         self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
342
343         # track values
344         track_ctx = dict(context)
345         if 'lang' not in track_ctx:
346             track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
347         if not context.get('mail_notrack'):
348             tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
349             if tracked_fields:
350                 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
351                 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
352         return thread_id
353
354     def write(self, cr, uid, ids, values, context=None):
355         if context is None:
356             context = {}
357         if isinstance(ids, (int, long)):
358             ids = [ids]
359         # Track initial values of tracked fields
360         track_ctx = dict(context)
361         if 'lang' not in track_ctx:
362             track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
363         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
364         if tracked_fields:
365             records = self.browse(cr, uid, ids, context=track_ctx)
366             initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
367
368         # Perform write, update followers
369         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
370         self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
371
372         if not context.get('mail_notrack'):
373             # Perform the tracking
374             tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
375         else:
376             tracked_fields = None
377         if tracked_fields:
378             self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
379         return result
380
381     def unlink(self, cr, uid, ids, context=None):
382         """ Override unlink to delete messages and followers. This cannot be
383             cascaded, because link is done through (res_model, res_id). """
384         msg_obj = self.pool.get('mail.message')
385         fol_obj = self.pool.get('mail.followers')
386         # delete messages and notifications
387         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
388         msg_obj.unlink(cr, uid, msg_ids, context=context)
389         # delete
390         res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
391         # delete followers
392         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
393         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
394         return res
395
396     def copy(self, cr, uid, id, default=None, context=None):
397         # avoid tracking multiple temporary changes during copy
398         context = dict(context or {}, mail_notrack=True)
399
400         default = default or {}
401         default['message_ids'] = []
402         default['message_follower_ids'] = []
403         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
404
405     #------------------------------------------------------
406     # Automatically log tracked fields
407     #------------------------------------------------------
408
409     def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
410         """ Return a structure of tracked fields for the current model.
411             :param list updated_fields: modified field names
412             :return list: a list of (field_name, column_info obj), containing
413                 always tracked fields and modified on_change fields
414         """
415         lst = []
416         for name, column_info in self._all_columns.items():
417             visibility = getattr(column_info.column, 'track_visibility', False)
418             if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
419                 lst.append(name)
420         if not lst:
421             return lst
422         return self.fields_get(cr, uid, lst, context=context)
423
424     def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
425
426         def convert_for_display(value, col_info):
427             if not value and col_info['type'] == 'boolean':
428                 return 'False'
429             if not value:
430                 return ''
431             if col_info['type'] == 'many2one':
432                 return value.name_get()[0][1]
433             if col_info['type'] == 'selection':
434                 return dict(col_info['selection'])[value]
435             return value
436
437         def format_message(message_description, tracked_values):
438             message = ''
439             if message_description:
440                 message = '<span>%s</span>' % message_description
441             for name, change in tracked_values.items():
442                 message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % change.get('col_info')
443                 if change.get('old_value'):
444                     message += '%s &rarr; ' % change.get('old_value')
445                 message += '%s</div>' % change.get('new_value')
446             return message
447
448         if not tracked_fields:
449             return True
450
451         for browse_record in self.browse(cr, uid, ids, context=context):
452             initial = initial_values[browse_record.id]
453             changes = set()
454             tracked_values = {}
455
456             # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
457             for col_name, col_info in tracked_fields.items():
458                 initial_value = initial[col_name]
459                 record_value = getattr(browse_record, col_name)
460
461                 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
462                     tracked_values[col_name] = dict(col_info=col_info['string'],
463                                                         new_value=convert_for_display(record_value, col_info))
464                 elif record_value != initial_value and (record_value or initial_value):  # because browse null != False
465                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
466                         tracked_values[col_name] = dict(col_info=col_info['string'],
467                                                             old_value=convert_for_display(initial_value, col_info),
468                                                             new_value=convert_for_display(record_value, col_info))
469                     if col_name in tracked_fields:
470                         changes.add(col_name)
471             if not changes:
472                 continue
473
474             # find subtypes and post messages or log if no subtype found
475             subtypes = []
476             for field, track_info in self._track.items():
477                 if field not in changes:
478                     continue
479                 for subtype, method in track_info.items():
480                     if method(self, cr, uid, browse_record, context):
481                         subtypes.append(subtype)
482
483             posted = False
484             for subtype in subtypes:
485                 try:
486                     subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
487                 except ValueError, e:
488                     _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
489                     continue
490                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
491                 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
492                 posted = True
493             if not posted:
494                 message = format_message('', tracked_values)
495                 self.message_post(cr, uid, browse_record.id, body=message, context=context)
496         return True
497
498     #------------------------------------------------------
499     # mail.message wrappers and tools
500     #------------------------------------------------------
501
502     def _needaction_domain_get(self, cr, uid, context=None):
503         if self._needaction:
504             return [('message_unread', '=', True)]
505         return []
506
507     def _garbage_collect_attachments(self, cr, uid, context=None):
508         """ Garbage collect lost mail attachments. Those are attachments
509             - linked to res_model 'mail.compose.message', the composer wizard
510             - with res_id 0, because they were created outside of an existing
511                 wizard (typically user input through Chatter or reports
512                 created on-the-fly by the templates)
513             - unused since at least one day (create_date and write_date)
514         """
515         limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
516         limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
517         ir_attachment_obj = self.pool.get('ir.attachment')
518         attach_ids = ir_attachment_obj.search(cr, uid, [
519                             ('res_model', '=', 'mail.compose.message'),
520                             ('res_id', '=', 0),
521                             ('create_date', '<', limit_date_str),
522                             ('write_date', '<', limit_date_str),
523                             ], context=context)
524         ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
525         return True
526
527     def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
528         """ mail.message check permission rules for related document. This method is
529             meant to be inherited in order to implement addons-specific behavior.
530             A common behavior would be to allow creating messages when having read
531             access rule on the document, for portal document such as issues. """
532         if not model_obj:
533             model_obj = self
534         if hasattr(self, '_mail_post_access'):
535             create_allow = self._mail_post_access
536         else:
537             create_allow = 'write'
538
539         if operation in ['write', 'unlink']:
540             check_operation = 'write'
541         elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
542             check_operation = create_allow
543         elif operation == 'create':
544             check_operation = 'write'
545         else:
546             check_operation = operation
547
548         model_obj.check_access_rights(cr, uid, check_operation)
549         model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
550
551     def _get_formview_action(self, cr, uid, id, model=None, context=None):
552         """ Return an action to open the document. This method is meant to be
553             overridden in addons that want to give specific view ids for example.
554
555             :param int id: id of the document to open
556             :param string model: specific model that overrides self._name
557         """
558         return {
559                 'type': 'ir.actions.act_window',
560                 'res_model': model or self._name,
561                 'view_type': 'form',
562                 'view_mode': 'form',
563                 'views': [(False, 'form')],
564                 'target': 'current',
565                 'res_id': id,
566             }
567
568     def _get_inbox_action_xml_id(self, cr, uid, context=None):
569         """ When redirecting towards the Inbox, choose which action xml_id has
570             to be fetched. This method is meant to be inherited, at least in portal
571             because portal users have a different Inbox action than classic users. """
572         return ('mail', 'action_mail_inbox_feeds')
573
574     def message_redirect_action(self, cr, uid, context=None):
575         """ For a given message, return an action that either
576             - opens the form view of the related document if model, res_id, and
577               read access to the document
578             - opens the Inbox with a default search on the conversation if model,
579               res_id
580             - opens the Inbox with context propagated
581
582         """
583         if context is None:
584             context = {}
585
586         # default action is the Inbox action
587         self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
588         act_model, act_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, *self._get_inbox_action_xml_id(cr, uid, context=context))
589         action = self.pool.get(act_model).read(cr, uid, act_id, [])
590         params = context.get('params')
591         msg_id = model = res_id = None
592
593         if params:
594             msg_id = params.get('message_id')
595             model = params.get('model')
596             res_id = params.get('res_id')
597         if not msg_id and not (model and res_id):
598             return action
599         if msg_id and not (model and res_id):
600             msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
601             if msg.exists():
602                 model, res_id = msg.model, msg.res_id
603
604         # if model + res_id found: try to redirect to the document or fallback on the Inbox
605         if model and res_id:
606             model_obj = self.pool.get(model)
607             if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
608                 try:
609                     model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
610                     if not hasattr(model_obj, '_get_formview_action'):
611                         action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
612                     else:
613                         action = model_obj._get_formview_action(cr, uid, res_id, context=context)
614                 except (osv.except_osv, orm.except_orm):
615                     pass
616             action.update({
617                 'context': {
618                     'search_default_model': model,
619                     'search_default_res_id': res_id,
620                 }
621             })
622         return action
623
624     #------------------------------------------------------
625     # Email specific
626     #------------------------------------------------------
627
628     def message_get_reply_to(self, cr, uid, ids, context=None):
629         """ Returns the preferred reply-to email address that is basically
630             the alias of the document, if it exists. """
631         if not self._inherits.get('mail.alias'):
632             return [False for id in ids]
633         return ["%s@%s" % (record['alias_name'], record['alias_domain'])
634                     if record.get('alias_domain') and record.get('alias_name')
635                     else False
636                     for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
637
638     #------------------------------------------------------
639     # Mail gateway
640     #------------------------------------------------------
641
642     def message_capable_models(self, cr, uid, context=None):
643         """ Used by the plugin addon, based for plugin_outlook and others. """
644         ret_dict = {}
645         for model_name in self.pool.obj_list():
646             model = self.pool[model_name]
647             if hasattr(model, "message_process") and hasattr(model, "message_post"):
648                 ret_dict[model_name] = model._description
649         return ret_dict
650
651     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
652         """ Find partners related to some header fields of the message.
653
654             :param string message: an email.message instance """
655         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
656         return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
657
658     def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
659         """ Verify route validity. Check and rules:
660             1 - if thread_id -> check that document effectively exists; otherwise
661                 fallback on a message_new by resetting thread_id
662             2 - check that message_update exists if thread_id is set; or at least
663                 that message_new exist
664             [ - find author_id if udpate_author is set]
665             3 - if there is an alias, check alias_contact:
666                 'followers' and thread_id:
667                     check on target document that the author is in the followers
668                 'followers' and alias_parent_thread_id:
669                     check on alias parent document that the author is in the
670                     followers
671                 'partners': check that author_id id set
672         """
673
674         assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
675         assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
676
677         message_id = message.get('Message-Id')
678         email_from = decode_header(message, 'From')
679         author_id = message_dict.get('author_id')
680         model, thread_id, alias = route[0], route[1], route[4]
681         model_pool = None
682
683         def _create_bounce_email():
684             mail_mail = self.pool.get('mail.mail')
685             mail_id = mail_mail.create(cr, uid, {
686                             'body_html': '<div><p>Hello,</p>'
687                                 '<p>The following email sent to %s cannot be accepted because this is '
688                                 'a private email address. Only allowed people can contact us at this address.</p></div>'
689                                 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
690                             'subject': 'Re: %s' % message.get('subject'),
691                             'email_to': message.get('from'),
692                             'auto_delete': True,
693                         }, context=context)
694             mail_mail.send(cr, uid, [mail_id], context=context)
695
696         def _warn(message):
697             _logger.warning('Routing mail with Message-Id %s: route %s: %s',
698                                 message_id, route, message)
699
700         # Wrong model
701         if model and not model in self.pool:
702             if assert_model:
703                 assert model in self.pool, 'Routing: unknown target model %s' % model
704             _warn('unknown target model %s' % model)
705             return ()
706         elif model:
707             model_pool = self.pool[model]
708
709         # Private message: should not contain any thread_id
710         if not model and thread_id:
711             if assert_model:
712                 if thread_id: 
713                     raise ValueError('Routing: posting a message without model should be with a null res_id (private message).')
714             _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
715             thread_id = 0
716         # Private message: should have a parent_id (only answers)
717         if not model and not message_dict.get('parent_id'):
718             if assert_model:
719                 if not message_dict.get('parent_id'):
720                     raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
721             _warn('posting a message without model should be with a parent_id (private mesage), skipping')
722             return ()
723
724         # Existing Document: check if exists; if not, fallback on create if allowed
725         if thread_id and not model_pool.exists(cr, uid, thread_id):
726             if create_fallback:
727                 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
728                 thread_id = None
729             elif assert_model:
730                 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
731             else:
732                 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
733                 return ()
734
735         # Existing Document: check model accepts the mailgateway
736         if thread_id and model and not hasattr(model_pool, 'message_update'):
737             if create_fallback:
738                 _warn('model %s does not accept document update, fall back on document creation' % model)
739                 thread_id = None
740             elif assert_model:
741                 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
742             else:
743                 _warn('model %s does not accept document update, skipping' % model)
744                 return ()
745
746         # New Document: check model accepts the mailgateway
747         if not thread_id and model and not hasattr(model_pool, 'message_new'):
748             if assert_model:
749                 if not hasattr(model_pool, 'message_new'):
750                     raise ValueError(
751                         'Model %s does not accept document creation, crashing' % model
752                     )
753             _warn('model %s does not accept document creation, skipping' % model)
754             return ()
755
756         # Update message author if asked
757         # We do it now because we need it for aliases (contact settings)
758         if not author_id and update_author:
759             author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
760             if author_ids:
761                 author_id = author_ids[0]
762                 message_dict['author_id'] = author_id
763
764         # Alias: check alias_contact settings
765         if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
766             if thread_id:
767                 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
768             else:
769                 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
770             if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
771                 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
772                 _create_bounce_email()
773                 return ()
774         elif alias and alias.alias_contact == 'partners' and not author_id:
775             _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
776             _create_bounce_email()
777             return ()
778
779         return (model, thread_id, route[2], route[3], route[4])
780
781     def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
782                       custom_values=None, context=None):
783         """Attempt to figure out the correct target model, thread_id,
784         custom_values and user_id to use for an incoming message.
785         Multiple values may be returned, if a message had multiple
786         recipients matching existing mail.aliases, for example.
787
788         The following heuristics are used, in this order:
789              1. If the message replies to an existing thread_id, and
790                 properly contains the thread model in the 'In-Reply-To'
791                 header, use this model/thread_id pair, and ignore
792                 custom_value (not needed as no creation will take place)
793              2. Look for a mail.alias entry matching the message
794                 recipient, and use the corresponding model, thread_id,
795                 custom_values and user_id.
796              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
797                 provided.
798              4. If all the above fails, raise an exception.
799
800            :param string message: an email.message instance
801            :param dict message_dict: dictionary holding message variables
802            :param string model: the fallback model to use if the message
803                does not match any of the currently configured mail aliases
804                (may be None if a matching alias is supposed to be present)
805            :type dict custom_values: optional dictionary of default field values
806                 to pass to ``message_new`` if a new record needs to be created.
807                 Ignored if the thread record already exists, and also if a
808                 matching mail.alias was found (aliases define their own defaults)
809            :param int thread_id: optional ID of the record/thread from ``model``
810                to which this mail should be attached. Only used if the message
811                does not reply to an existing thread and does not match any mail alias.
812            :return: list of [model, thread_id, custom_values, user_id, alias]
813
814         :raises: ValueError, TypeError
815         """
816         if not isinstance(message, Message):
817             raise TypeError('message must be an email.message.Message at this point')
818         fallback_model = model
819
820         # Get email.message.Message variables for future processing
821         message_id = message.get('Message-Id')
822         email_from = decode_header(message, 'From')
823         email_to = decode_header(message, 'To')
824         references = decode_header(message, 'References')
825         in_reply_to = decode_header(message, 'In-Reply-To')
826
827         # 1. Verify if this is a reply to an existing thread
828         thread_references = references or in_reply_to
829         ref_match = thread_references and tools.reference_re.search(thread_references)
830         if ref_match:
831             thread_id = int(ref_match.group(1))
832             model = ref_match.group(2) or fallback_model
833             if thread_id and model in self.pool:
834                 model_obj = self.pool[model]
835                 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
836                     _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',
837                                     email_from, email_to, message_id, model, thread_id, custom_values, uid)
838                     route = self.message_route_verify(cr, uid, message, message_dict,
839                                     (model, thread_id, custom_values, uid, None),
840                                     update_author=True, assert_model=True, create_fallback=True, context=context)
841                     return route and [route] or []
842
843         # 2. Reply to a private message
844         if in_reply_to:
845             mail_message_ids = self.pool.get('mail.message').search(cr, uid, [
846                                 ('message_id', '=', in_reply_to),
847                                 '!', ('message_id', 'ilike', 'reply_to')
848                             ], limit=1, context=context)
849             if mail_message_ids:
850                 mail_message = self.pool.get('mail.message').browse(cr, uid, mail_message_ids[0], context=context)
851                 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
852                                 email_from, email_to, message_id, mail_message.id, custom_values, uid)
853                 route = self.message_route_verify(cr, uid, message, message_dict,
854                                 (mail_message.model, mail_message.res_id, custom_values, uid, None),
855                                 update_author=True, assert_model=True, create_fallback=True, context=context)
856                 return route and [route] or []
857
858         # 3. Look for a matching mail.alias entry
859         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
860         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
861         rcpt_tos = \
862              ','.join([decode_header(message, 'Delivered-To'),
863                        decode_header(message, 'To'),
864                        decode_header(message, 'Cc'),
865                        decode_header(message, 'Resent-To'),
866                        decode_header(message, 'Resent-Cc')])
867         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
868         if local_parts:
869             mail_alias = self.pool.get('mail.alias')
870             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
871             if alias_ids:
872                 routes = []
873                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
874                     user_id = alias.alias_user_id.id
875                     if not user_id:
876                         # TDE note: this could cause crashes, because no clue that the user
877                         # that send the email has the right to create or modify a new document
878                         # Fallback on user_id = uid
879                         # Note: recognized partners will be added as followers anyway
880                         # user_id = self._message_find_user_id(cr, uid, message, context=context)
881                         user_id = uid
882                         _logger.info('No matching user_id for the alias %s', alias.alias_name)
883                     route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
884                     _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
885                                 email_from, email_to, message_id, route)
886                     route = self.message_route_verify(cr, uid, message, message_dict, route,
887                                 update_author=True, assert_model=True, create_fallback=True, context=context)
888                     if route:
889                         routes.append(route)
890                 return routes
891
892         # 4. Fallback to the provided parameters, if they work
893         if not thread_id:
894             # Legacy: fallback to matching [ID] in the Subject
895             match = tools.res_re.search(decode_header(message, 'Subject'))
896             thread_id = match and match.group(1)
897             # Convert into int (bug spotted in 7.0 because of str)
898             try:
899                 thread_id = int(thread_id)
900             except:
901                 thread_id = False
902         _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
903                     email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
904         route = self.message_route_verify(cr, uid, message, message_dict,
905                         (fallback_model, thread_id, custom_values, uid, None),
906                         update_author=True, assert_model=True, context=context)
907         if route:
908             return [route]
909
910         # AssertionError if no routes found and if no bounce occured
911         raise ValueError(
912                 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
913                 'Create an appropriate mail.alias or force the destination model.' %
914                 (email_from, email_to, message_id)
915             )
916
917     def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
918         # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
919         partner_ids = message_dict.pop('partner_ids', [])
920         thread_id = False
921         for model, thread_id, custom_values, user_id, alias in routes:
922             if self._name == 'mail.thread':
923                 context.update({'thread_model': model})
924             if model:
925                 model_pool = self.pool[model]
926                 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
927                     raise ValueError(
928                         "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
929                         (message_dict['message_id'], model)
930                     )
931
932                 # disabled subscriptions during message_new/update to avoid having the system user running the
933                 # email gateway become a follower of all inbound messages
934                 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
935                 if thread_id and hasattr(model_pool, 'message_update'):
936                     model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
937                 else:
938                     thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
939             else:
940                 if thread_id:
941                     raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
942                 model_pool = self.pool.get('mail.thread')
943             if not hasattr(model_pool, 'message_post'):
944                 context['thread_model'] = model
945                 model_pool = self.pool['mail.thread']
946             new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
947
948             if partner_ids:
949                 # postponed after message_post, because this is an external message and we don't want to create
950                 # duplicate emails due to notifications
951                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
952         return thread_id
953
954     def message_process(self, cr, uid, model, message, custom_values=None,
955                         save_original=False, strip_attachments=False,
956                         thread_id=None, context=None):
957         """ Process an incoming RFC2822 email message, relying on
958             ``mail.message.parse()`` for the parsing operation,
959             and ``message_route()`` to figure out the target model.
960
961             Once the target model is known, its ``message_new`` method
962             is called with the new message (if the thread record did not exist)
963             or its ``message_update`` method (if it did).
964
965             There is a special case where the target model is False: a reply
966             to a private message. In this case, we skip the message_new /
967             message_update step, to just post a new message using mail_thread
968             message_post.
969
970            :param string model: the fallback model to use if the message
971                does not match any of the currently configured mail aliases
972                (may be None if a matching alias is supposed to be present)
973            :param message: source of the RFC2822 message
974            :type message: string or xmlrpclib.Binary
975            :type dict custom_values: optional dictionary of field values
976                 to pass to ``message_new`` if a new record needs to be created.
977                 Ignored if the thread record already exists, and also if a
978                 matching mail.alias was found (aliases define their own defaults)
979            :param bool save_original: whether to keep a copy of the original
980                 email source attached to the message after it is imported.
981            :param bool strip_attachments: whether to strip all attachments
982                 before processing the message, in order to save some space.
983            :param int thread_id: optional ID of the record/thread from ``model``
984                to which this mail should be attached. When provided, this
985                overrides the automatic detection based on the message
986                headers.
987         """
988         if context is None:
989             context = {}
990
991         # extract message bytes - we are forced to pass the message as binary because
992         # we don't know its encoding until we parse its headers and hence can't
993         # convert it to utf-8 for transport between the mailgate script and here.
994         if isinstance(message, xmlrpclib.Binary):
995             message = str(message.data)
996         # Warning: message_from_string doesn't always work correctly on unicode,
997         # we must use utf-8 strings here :-(
998         if isinstance(message, unicode):
999             message = message.encode('utf-8')
1000         msg_txt = email.message_from_string(message)
1001
1002         # parse the message, verify we are not in a loop by checking message_id is not duplicated
1003         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1004         if strip_attachments:
1005             msg.pop('attachments', None)
1006
1007         if msg.get('message_id'):   # should always be True as message_parse generate one if missing
1008             existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1009                                                                 ('message_id', '=', msg.get('message_id')),
1010                                                                 ], context=context)
1011             if existing_msg_ids:
1012                 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1013                                 msg.get('from'), msg.get('to'), msg.get('message_id'))
1014                 return False
1015
1016         # find possible routes for the message
1017         routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1018         thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1019         return thread_id
1020
1021     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1022         """Called by ``message_process`` when a new message is received
1023            for a given thread model, if the message did not belong to
1024            an existing thread.
1025            The default behavior is to create a new record of the corresponding
1026            model (based on some very basic info extracted from the message).
1027            Additional behavior may be implemented by overriding this method.
1028
1029            :param dict msg_dict: a map containing the email details and
1030                                  attachments. See ``message_process`` and
1031                                 ``mail.message.parse`` for details.
1032            :param dict custom_values: optional dictionary of additional
1033                                       field values to pass to create()
1034                                       when creating the new thread record.
1035                                       Be careful, these values may override
1036                                       any other values coming from the message.
1037            :param dict context: if a ``thread_model`` value is present
1038                                 in the context, its value will be used
1039                                 to determine the model of the record
1040                                 to create (instead of the current model).
1041            :rtype: int
1042            :return: the id of the newly created thread object
1043         """
1044         if context is None:
1045             context = {}
1046         data = {}
1047         if isinstance(custom_values, dict):
1048             data = custom_values.copy()
1049         model = context.get('thread_model') or self._name
1050         model_pool = self.pool[model]
1051         fields = model_pool.fields_get(cr, uid, context=context)
1052         if 'name' in fields and not data.get('name'):
1053             data['name'] = msg_dict.get('subject', '')
1054         res_id = model_pool.create(cr, uid, data, context=context)
1055         return res_id
1056
1057     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1058         """Called by ``message_process`` when a new message is received
1059            for an existing thread. The default behavior is to update the record
1060            with update_vals taken from the incoming email.
1061            Additional behavior may be implemented by overriding this
1062            method.
1063            :param dict msg_dict: a map containing the email details and
1064                                attachments. See ``message_process`` and
1065                                ``mail.message.parse()`` for details.
1066            :param dict update_vals: a dict containing values to update records
1067                               given their ids; if the dict is None or is
1068                               void, no write operation is performed.
1069         """
1070         if update_vals:
1071             self.write(cr, uid, ids, update_vals, context=context)
1072         return True
1073
1074     def _message_extract_payload(self, message, save_original=False):
1075         """Extract body as HTML and attachments from the mail message"""
1076         attachments = []
1077         body = u''
1078         if save_original:
1079             attachments.append(('original_email.eml', message.as_string()))
1080         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1081             encoding = message.get_content_charset()
1082             body = message.get_payload(decode=True)
1083             body = tools.ustr(body, encoding, errors='replace')
1084             if message.get_content_type() == 'text/plain':
1085                 # text/plain -> <pre/>
1086                 body = tools.append_content_to_html(u'', body, preserve=True)
1087         else:
1088             alternative = False
1089             for part in message.walk():
1090                 if part.get_content_type() == 'multipart/alternative':
1091                     alternative = True
1092                 if part.get_content_maintype() == 'multipart':
1093                     continue  # skip container
1094                 # part.get_filename returns decoded value if able to decode, coded otherwise.
1095                 # original get_filename is not able to decode iso-8859-1 (for instance).
1096                 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1097                 # code here partially copy the original get_filename method, but handle more encoding
1098                 filename=part.get_param('filename', None, 'content-disposition')
1099                 if not filename:
1100                     filename=part.get_param('name', None)
1101                 if filename:
1102                     if isinstance(filename, tuple):
1103                         # RFC2231
1104                         filename=email.utils.collapse_rfc2231_value(filename).strip()
1105                     else:
1106                         filename=decode(filename)
1107                 encoding = part.get_content_charset()  # None if attachment
1108                 # 1) Explicit Attachments -> attachments
1109                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1110                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1111                     continue
1112                 # 2) text/plain -> <pre/>
1113                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1114                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1115                                                                          encoding, errors='replace'), preserve=True)
1116                 # 3) text/html -> raw
1117                 elif part.get_content_type() == 'text/html':
1118                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1119                     if alternative:
1120                         body = html
1121                     else:
1122                         body = tools.append_content_to_html(body, html, plaintext=False)
1123                 # 4) Anything else -> attachment
1124                 else:
1125                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1126         return body, attachments
1127
1128     def message_parse(self, cr, uid, message, save_original=False, context=None):
1129         """Parses a string or email.message.Message representing an
1130            RFC-2822 email, and returns a generic dict holding the
1131            message details.
1132
1133            :param message: the message to parse
1134            :type message: email.message.Message | string | unicode
1135            :param bool save_original: whether the returned dict
1136                should include an ``original`` attachment containing
1137                the source of the message
1138            :rtype: dict
1139            :return: A dict with the following structure, where each
1140                     field may not be present if missing in original
1141                     message::
1142
1143                     { 'message_id': msg_id,
1144                       'subject': subject,
1145                       'from': from,
1146                       'to': to,
1147                       'cc': cc,
1148                       'body': unified_body,
1149                       'attachments': [('file1', 'bytes'),
1150                                       ('file2', 'bytes')}
1151                     }
1152         """
1153         msg_dict = {
1154             'type': 'email',
1155         }
1156         if not isinstance(message, Message):
1157             if isinstance(message, unicode):
1158                 # Warning: message_from_string doesn't always work correctly on unicode,
1159                 # we must use utf-8 strings here :-(
1160                 message = message.encode('utf-8')
1161             message = email.message_from_string(message)
1162
1163         message_id = message['message-id']
1164         if not message_id:
1165             # Very unusual situation, be we should be fault-tolerant here
1166             message_id = "<%s@localhost>" % time.time()
1167             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1168         msg_dict['message_id'] = message_id
1169
1170         if message.get('Subject'):
1171             msg_dict['subject'] = decode(message.get('Subject'))
1172
1173         # Envelope fields not stored in mail.message but made available for message_new()
1174         msg_dict['from'] = decode(message.get('from'))
1175         msg_dict['to'] = decode(message.get('to'))
1176         msg_dict['cc'] = decode(message.get('cc'))
1177         msg_dict['email_from'] = decode(message.get('from'))
1178         partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1179         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1180
1181         if message.get('Date'):
1182             try:
1183                 date_hdr = decode(message.get('Date'))
1184                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1185                 if parsed_date.utcoffset() is None:
1186                     # naive datetime, so we arbitrarily decide to make it
1187                     # UTC, there's no better choice. Should not happen,
1188                     # as RFC2822 requires timezone offset in Date headers.
1189                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
1190                 else:
1191                     stored_date = parsed_date.astimezone(tz=pytz.utc)
1192             except Exception:
1193                 _logger.warning('Failed to parse Date header %r in incoming mail '
1194                                 'with message-id %r, assuming current date/time.',
1195                                 message.get('Date'), message_id)
1196                 stored_date = datetime.datetime.now()
1197             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1198
1199         if message.get('In-Reply-To'):
1200             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1201             if parent_ids:
1202                 msg_dict['parent_id'] = parent_ids[0]
1203
1204         if message.get('References') and 'parent_id' not in msg_dict:
1205             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1206                                                                          [x.strip() for x in decode(message['References']).split()])])
1207             if parent_ids:
1208                 msg_dict['parent_id'] = parent_ids[0]
1209
1210         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1211         return msg_dict
1212
1213     #------------------------------------------------------
1214     # Note specific
1215     #------------------------------------------------------
1216
1217     def log(self, cr, uid, id, message, secondary=False, context=None):
1218         _logger.warning("log() is deprecated. As this module inherit from "\
1219                         "mail.thread, the message will be managed by this "\
1220                         "module instead of by the res.log mechanism. Please "\
1221                         "use mail_thread.message_post() instead of the "\
1222                         "now deprecated res.log.")
1223         self.message_post(cr, uid, [id], message, context=context)
1224
1225     def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1226         """ Called by message_get_suggested_recipients, to add a suggested
1227             recipient in the result dictionary. The form is :
1228                 partner_id, partner_name<partner_email> or partner_name, reason """
1229         if email and not partner:
1230             # get partner info from email
1231             partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1232             if partner_info.get('partner_id'):
1233                 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1234         if email and email in [val[1] for val in result[obj.id]]:  # already existing email -> skip
1235             return result
1236         if partner and partner in obj.message_follower_ids:  # recipient already in the followers -> skip
1237             return result
1238         if partner and partner in [val[0] for val in result[obj.id]]:  # already existing partner ID -> skip
1239             return result
1240         if partner and partner.email:  # complete profile: id, name <email>
1241             result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1242         elif partner:  # incomplete profile: id, name
1243             result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1244         else:  # unknown partner, we are probably managing an email address
1245             result[obj.id].append((False, email, reason))
1246         return result
1247
1248     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1249         """ Returns suggested recipients for ids. Those are a list of
1250             tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1251         result = dict.fromkeys(ids, list())
1252         if self._all_columns.get('user_id'):
1253             for obj in self.browse(cr, SUPERUSER_ID, ids, context=context):  # SUPERUSER because of a read on res.users that would crash otherwise
1254                 if not obj.user_id or not obj.user_id.partner_id:
1255                     continue
1256                 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)
1257         return result
1258
1259     def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1260         """ Utility method to find partners from email addresses. The rules are :
1261             1 - check in document (model | self, id) followers
1262             2 - try to find a matching partner that is also an user
1263             3 - try to find a matching partner
1264
1265             :param list emails: list of email addresses
1266             :param string model: model to fetch related record; by default self
1267                 is used.
1268             :param boolean check_followers: check in document followers
1269         """
1270         partner_obj = self.pool['res.partner']
1271         partner_ids = []
1272         obj = None
1273         if id and (model or self._name != 'mail.thread') and check_followers:
1274             if model:
1275                 obj = self.pool[model].browse(cr, uid, id, context=context)
1276             else:
1277                 obj = self.browse(cr, uid, id, context=context)
1278         for contact in emails:
1279             partner_id = False
1280             email_address = tools.email_split(contact)
1281             if not email_address:
1282                 partner_ids.append(partner_id)
1283                 continue
1284             email_address = email_address[0]
1285             # first try: check in document's followers
1286             if obj:
1287                 for follower in obj.message_follower_ids:
1288                     if follower.email == email_address:
1289                         partner_id = follower.id
1290             # second try: check in partners that are also users
1291             if not partner_id:
1292                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1293                                                 ('email', 'ilike', email_address),
1294                                                 ('user_ids', '!=', False)
1295                                             ], limit=1, context=context)
1296                 if ids:
1297                     partner_id = ids[0]
1298             # third try: check in partners
1299             if not partner_id:
1300                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1301                                                 ('email', 'ilike', email_address)
1302                                             ], limit=1, context=context)
1303                 if ids:
1304                     partner_id = ids[0]
1305             partner_ids.append(partner_id)
1306         return partner_ids
1307
1308     def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1309         """ Convert a list of emails into a list partner_ids and a list
1310             new_partner_ids. The return value is non conventional because
1311             it is meant to be used by the mail widget.
1312
1313             :return dict: partner_ids and new_partner_ids """
1314         mail_message_obj = self.pool.get('mail.message')
1315         partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1316         result = list()
1317         for idx in range(len(emails)):
1318             email_address = emails[idx]
1319             partner_id = partner_ids[idx]
1320             partner_info = {'full_name': email_address, 'partner_id': partner_id}
1321             result.append(partner_info)
1322
1323             # link mail with this from mail to the new partner id
1324             if link_mail and partner_info['partner_id']:
1325                 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1326                                     '|',
1327                                     ('email_from', '=', email_address),
1328                                     ('email_from', 'ilike', '<%s>' % email_address),
1329                                     ('author_id', '=', False)
1330                                 ], context=context)
1331                 if message_ids:
1332                     mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1333         return result
1334
1335     def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1336         """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1337
1338         :param list attachments: list of attachment tuples in the form ``(name,content)``,
1339                                  where content is NOT base64 encoded
1340         :param list attachment_ids: a list of attachment ids, not in tomany command form
1341         :param str attach_model: the model of the attachments parent record
1342         :param integer attach_res_id: the id of the attachments parent record
1343         """
1344         Attachment = self.pool['ir.attachment']
1345         m2m_attachment_ids = []
1346         if attachment_ids:
1347             filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1348                 ('res_model', '=', 'mail.compose.message'),
1349                 ('create_uid', '=', uid),
1350                 ('id', 'in', attachment_ids)], context=context)
1351             if filtered_attachment_ids:
1352                 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1353             m2m_attachment_ids += [(4, id) for id in attachment_ids]
1354         # Handle attachments parameter, that is a dictionary of attachments
1355         for name, content in attachments:
1356             if isinstance(content, unicode):
1357                 content = content.encode('utf-8')
1358             data_attach = {
1359                 'name': name,
1360                 'datas': base64.b64encode(str(content)),
1361                 'datas_fname': name,
1362                 'description': name,
1363                 'res_model': attach_model,
1364                 'res_id': attach_res_id,
1365             }
1366             m2m_attachment_ids.append((0, 0, data_attach))
1367         return m2m_attachment_ids
1368
1369     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1370                      subtype=None, parent_id=False, attachments=None, context=None,
1371                      content_subtype='html', **kwargs):
1372         """ Post a new message in an existing thread, returning the new
1373             mail.message ID.
1374
1375             :param int thread_id: thread ID to post into, or list with one ID;
1376                 if False/0, mail.message model will also be set as False
1377             :param str body: body of the message, usually raw HTML that will
1378                 be sanitized
1379             :param str type: see mail_message.type field
1380             :param str content_subtype:: if plaintext: convert body into html
1381             :param int parent_id: handle reply to a previous message by adding the
1382                 parent partners to the message in case of private discussion
1383             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1384                 ``(name,content)``, where content is NOT base64 encoded
1385
1386             Extra keyword arguments will be used as default column values for the
1387             new mail.message record. Special cases:
1388                 - attachment_ids: supposed not attached to any document; attach them
1389                     to the related document. Should only be set by Chatter.
1390             :return int: ID of newly created mail.message
1391         """
1392         if context is None:
1393             context = {}
1394         if attachments is None:
1395             attachments = {}
1396         mail_message = self.pool.get('mail.message')
1397         ir_attachment = self.pool.get('ir.attachment')
1398
1399         assert (not thread_id) or \
1400                 isinstance(thread_id, (int, long)) or \
1401                 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1402                 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1403         if isinstance(thread_id, (list, tuple)):
1404             thread_id = thread_id[0]
1405
1406         # if we're processing a message directly coming from the gateway, the destination model was
1407         # set in the context.
1408         model = False
1409         if thread_id:
1410             model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1411             if model != self._name and hasattr(self.pool[model], 'message_post'):
1412                 del context['thread_model']
1413                 return self.pool[model].message_post(cr, uid, thread_id, body=body, subject=subject, type=type, subtype=subtype, parent_id=parent_id, attachments=attachments, context=context, content_subtype=content_subtype, **kwargs)
1414
1415         #0: Find the message's author, because we need it for private discussion
1416         author_id = kwargs.get('author_id')
1417         if author_id is None:  # keep False values
1418             author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1419
1420         # 1: Handle content subtype: if plaintext, converto into HTML
1421         if content_subtype == 'plaintext':
1422             body = tools.plaintext2html(body)
1423
1424         # 2: Private message: add recipients (recipients and author of parent message) - current author
1425         #   + legacy-code management (! we manage only 4 and 6 commands)
1426         partner_ids = set()
1427         kwargs_partner_ids = kwargs.pop('partner_ids', [])
1428         for partner_id in kwargs_partner_ids:
1429             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1430                 partner_ids.add(partner_id[1])
1431             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1432                 partner_ids |= set(partner_id[2])
1433             elif isinstance(partner_id, (int, long)):
1434                 partner_ids.add(partner_id)
1435             else:
1436                 pass  # we do not manage anything else
1437         if parent_id and not model:
1438             parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1439             private_followers = set([partner.id for partner in parent_message.partner_ids])
1440             if parent_message.author_id:
1441                 private_followers.add(parent_message.author_id.id)
1442             private_followers -= set([author_id])
1443             partner_ids |= private_followers
1444
1445         # 3. Attachments
1446         #   - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1447         attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1448
1449         # 4: mail.message.subtype
1450         subtype_id = False
1451         if subtype:
1452             if '.' not in subtype:
1453                 subtype = 'mail.%s' % subtype
1454             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1455             subtype_id = ref and ref[1] or False
1456
1457         # automatically subscribe recipients if asked to
1458         if context.get('mail_post_autofollow') and thread_id and partner_ids:
1459             partner_to_subscribe = partner_ids
1460             if context.get('mail_post_autofollow_partner_ids'):
1461                 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1462             self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1463
1464         # _mail_flat_thread: automatically set free messages to the first posted message
1465         if self._mail_flat_thread and not parent_id and thread_id:
1466             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1467             parent_id = message_ids and message_ids[0] or False
1468         # 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
1469         elif parent_id:
1470             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1471             # avoid loops when finding ancestors
1472             processed_list = []
1473             if message_ids:
1474                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1475                 while (message.parent_id and message.parent_id.id not in processed_list):
1476                     processed_list.append(message.parent_id.id)
1477                     message = message.parent_id
1478                 parent_id = message.id
1479
1480         values = kwargs
1481         values.update({
1482             'author_id': author_id,
1483             'model': model,
1484             'res_id': thread_id or False,
1485             'body': body,
1486             'subject': subject or False,
1487             'type': type,
1488             'parent_id': parent_id,
1489             'attachment_ids': attachment_ids,
1490             'subtype_id': subtype_id,
1491             'partner_ids': [(4, pid) for pid in partner_ids],
1492         })
1493
1494         # Avoid warnings about non-existing fields
1495         for x in ('from', 'to', 'cc'):
1496             values.pop(x, None)
1497
1498         # Create and auto subscribe the author
1499         msg_id = mail_message.create(cr, uid, values, context=context)
1500         message = mail_message.browse(cr, uid, msg_id, context=context)
1501         if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1502             self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1503         return msg_id
1504
1505     #------------------------------------------------------
1506     # Followers API
1507     #------------------------------------------------------
1508
1509     def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1510         """ Wrapper to get subtypes data. """
1511         return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1512
1513     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1514         """ Wrapper on message_subscribe, using users. If user_ids is not
1515             provided, subscribe uid instead. """
1516         if user_ids is None:
1517             user_ids = [uid]
1518         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1519         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1520
1521     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1522         """ Add partners to the records followers. """
1523         if context is None:
1524             context = {}
1525         # not necessary for computation, but saves an access right check
1526         if not partner_ids:
1527             return True
1528
1529         mail_followers_obj = self.pool.get('mail.followers')
1530         subtype_obj = self.pool.get('mail.message.subtype')
1531
1532         user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1533         if set(partner_ids) == set([user_pid]):
1534             try:
1535                 self.check_access_rights(cr, uid, 'read')
1536                 if context.get('operation', '') == 'create':
1537                     self.check_access_rule(cr, uid, ids, 'create')
1538                 else:
1539                     self.check_access_rule(cr, uid, ids, 'read')
1540             except (osv.except_osv, orm.except_orm):
1541                 return False
1542         else:
1543             self.check_access_rights(cr, uid, 'write')
1544             self.check_access_rule(cr, uid, ids, 'write')
1545
1546         existing_pids_dict = {}
1547         fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1548         for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1549             existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1550
1551         # subtype_ids specified: update already subscribed partners
1552         if subtype_ids and fol_ids:
1553             mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1554         # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1555         if subtype_ids is None:
1556             subtype_ids = subtype_obj.search(
1557                 cr, uid, [
1558                     ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1559
1560         for id in ids:
1561             existing_pids = existing_pids_dict.get(id, set())
1562             new_pids = set(partner_ids) - existing_pids
1563
1564             # subscribe new followers
1565             for new_pid in new_pids:
1566                 mail_followers_obj.create(
1567                     cr, SUPERUSER_ID, {
1568                         'res_model': self._name,
1569                         'res_id': id,
1570                         'partner_id': new_pid,
1571                         'subtype_ids': [(6, 0, subtype_ids)],
1572                     }, context=context)
1573
1574         return True
1575
1576     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1577         """ Wrapper on message_subscribe, using users. If user_ids is not
1578             provided, unsubscribe uid instead. """
1579         if user_ids is None:
1580             user_ids = [uid]
1581         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1582         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1583
1584     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1585         """ Remove partners from the records followers. """
1586         # not necessary for computation, but saves an access right check
1587         if not partner_ids:
1588             return True
1589         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1590         if set(partner_ids) == set([user_pid]):
1591             self.check_access_rights(cr, uid, 'read')
1592             self.check_access_rule(cr, uid, ids, 'read')
1593         else:
1594             self.check_access_rights(cr, uid, 'write')
1595             self.check_access_rule(cr, uid, ids, 'write')
1596         fol_obj = self.pool['mail.followers']
1597         fol_ids = fol_obj.search(
1598             cr, SUPERUSER_ID, [
1599                 ('res_model', '=', self._name),
1600                 ('res_id', 'in', ids),
1601                 ('partner_id', 'in', partner_ids)
1602             ], context=context)
1603         return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1604
1605     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1606         """ Returns the list of relational fields linking to res.users that should
1607             trigger an auto subscribe. The default list checks for the fields
1608             - called 'user_id'
1609             - linking to res.users
1610             - with track_visibility set
1611             In OpenERP V7, this is sufficent for all major addon such as opportunity,
1612             project, issue, recruitment, sale.
1613             Override this method if a custom behavior is needed about fields
1614             that automatically subscribe users.
1615         """
1616         user_field_lst = []
1617         for name, column_info in self._all_columns.items():
1618             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':
1619                 user_field_lst.append(name)
1620         return user_field_lst
1621
1622     def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1623         """ Handle auto subscription. Two methods for auto subscription exist:
1624
1625          - tracked res.users relational fields, such as user_id fields. Those fields
1626            must be relation fields toward a res.users record, and must have the
1627            track_visilibity attribute set.
1628          - using subtypes parent relationship: check if the current model being
1629            modified has an header record (such as a project for tasks) whose followers
1630            can be added as followers of the current records. Example of structure
1631            with project and task:
1632
1633           - st_project_1.parent_id = st_task_1
1634           - st_project_1.res_model = 'project.project'
1635           - st_project_1.relation_field = 'project_id'
1636           - st_task_1.model = 'project.task'
1637
1638         :param list updated_fields: list of updated fields to track
1639         :param dict values: updated values; if None, the first record will be browsed
1640                             to get the values. Added after releasing 7.0, therefore
1641                             not merged with updated_fields argumment.
1642         """
1643         subtype_obj = self.pool.get('mail.message.subtype')
1644         follower_obj = self.pool.get('mail.followers')
1645         new_followers = dict()
1646
1647         # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1648         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1649
1650         # fetch header subtypes
1651         header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1652         subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1653
1654         # if no change in tracked field or no change in tracked relational field: quit
1655         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1656         if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1657             return True
1658
1659         # legacy behavior: if values is not given, compute the values by browsing
1660         # @TDENOTE: remove me in 8.0
1661         if values is None:
1662             record = self.browse(cr, uid, ids[0], context=context)
1663             for updated_field in updated_fields:
1664                 field_value = getattr(record, updated_field)
1665                 if isinstance(field_value, browse_record):
1666                     field_value = field_value.id
1667                 elif isinstance(field_value, browse_null):
1668                     field_value = False
1669                 values[updated_field] = field_value
1670
1671         # find followers of headers, update structure for new followers
1672         headers = set()
1673         for subtype in subtypes:
1674             if subtype.relation_field and values.get(subtype.relation_field):
1675                 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1676         if headers:
1677             header_domain = ['|'] * (len(headers) - 1)
1678             for header in headers:
1679                 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1680             header_follower_ids = follower_obj.search(
1681                 cr, SUPERUSER_ID,
1682                 header_domain,
1683                 context=context
1684             )
1685             for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1686                 for subtype in header_follower.subtype_ids:
1687                     if subtype.parent_id and subtype.parent_id.res_model == self._name:
1688                         new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1689                     elif subtype.res_model is False:
1690                         new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1691
1692         # add followers coming from res.users relational fields that are tracked
1693         user_ids = [values[name] for name in user_field_lst if values.get(name)]
1694         user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1695         for partner_id in user_pids:
1696             new_followers.setdefault(partner_id, None)
1697
1698         for pid, subtypes in new_followers.items():
1699             subtypes = list(subtypes) if subtypes is not None else None
1700             self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1701
1702         # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1703         if user_pids:
1704             for record_id in ids:
1705                 message_obj = self.pool.get('mail.message')
1706                 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1707                     ('model', '=', self._name),
1708                     ('res_id', '=', record_id),
1709                     ('type', '=', 'email')], limit=1, context=context)
1710                 if not msg_ids:
1711                     msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1712                         ('model', '=', self._name),
1713                         ('res_id', '=', record_id)], limit=1, context=context)
1714                 if msg_ids:
1715                     self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1716
1717         return True
1718
1719     #------------------------------------------------------
1720     # Thread state
1721     #------------------------------------------------------
1722
1723     def message_mark_as_unread(self, cr, uid, ids, context=None):
1724         """ Set as unread. """
1725         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1726         cr.execute('''
1727             UPDATE mail_notification SET
1728                 read=false
1729             WHERE
1730                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1731                 partner_id = %s
1732         ''', (ids, self._name, partner_id))
1733         return True
1734
1735     def message_mark_as_read(self, cr, uid, ids, context=None):
1736         """ Set as read. """
1737         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1738         cr.execute('''
1739             UPDATE mail_notification SET
1740                 read=true
1741             WHERE
1742                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1743                 partner_id = %s
1744         ''', (ids, self._name, partner_id))
1745         return True
1746
1747     #------------------------------------------------------
1748     # Thread suggestion
1749     #------------------------------------------------------
1750
1751     def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1752         """Return a list of suggested threads, sorted by the numbers of followers"""
1753         if context is None:
1754             context = {}
1755
1756         # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1757         # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1758         if self.pool['res.groups']._all_columns.get('is_portal'):
1759             user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1760             if any(group.is_portal for group in user.groups_id):
1761                 return []
1762
1763         threads = []
1764         if removed_suggested_threads is None:
1765             removed_suggested_threads = []
1766
1767         thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1768         for thread in self.browse(cr, uid, thread_ids, context=context):
1769             data = {
1770                 'id': thread.id,
1771                 'popularity': len(thread.message_follower_ids),
1772                 'name': thread.name,
1773                 'image_small': thread.image_small
1774             }
1775             threads.append(data)
1776         return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]