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