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