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