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