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