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