[FIX] mail_thread: fixed duplicate emails when replying to an incoming email.
[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
37 from openerp.tools.safe_eval import safe_eval as eval
38
39 _logger = logging.getLogger(__name__)
40
41
42 def decode_header(message, header, separator=' '):
43     return separator.join(map(decode, filter(None, message.get_all(header, []))))
44
45
46 class mail_thread(osv.AbstractModel):
47     ''' mail_thread model is meant to be inherited by any model that needs to
48         act as a discussion topic on which messages can be attached. Public
49         methods are prefixed with ``message_`` in order to avoid name
50         collisions with methods of the models that will inherit from this class.
51
52         ``mail.thread`` defines fields used to handle and display the
53         communication history. ``mail.thread`` also manages followers of
54         inheriting classes. All features and expected behavior are managed
55         by mail.thread. Widgets has been designed for the 7.0 and following
56         versions of OpenERP.
57
58         Inheriting classes are not required to implement any method, as the
59         default implementation will work for any model. However it is common
60         to override at least the ``message_new`` and ``message_update``
61         methods (calling ``super``) to add model-specific behavior at
62         creation and update of a thread when processing incoming emails.
63
64         Options:
65             - _mail_flat_thread: if set to True, all messages without parent_id
66                 are automatically attached to the first message posted on the
67                 ressource. If set to False, the display of Chatter is done using
68                 threads, and no parent_id is automatically set.
69     '''
70     _name = 'mail.thread'
71     _description = 'Email Thread'
72     _mail_flat_thread = True
73
74     # Automatic logging system if mail installed
75     # _track = {
76     #   'field': {
77     #       'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
78     #       'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
79     #   },
80     #   'field2': {
81     #       ...
82     #   },
83     # }
84     # where
85     #   :param string field: field name
86     #   :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
87     #   :param obj: is a browse_record
88     #   :param function lambda: returns whether the tracking should record using this subtype
89     _track = {}
90
91     def _get_message_data(self, cr, uid, ids, name, args, context=None):
92         """ Computes:
93             - message_unread: has uid unread message for the document
94             - message_summary: html snippet summarizing the Chatter for kanban views """
95         res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
96         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
97
98         # search for unread messages, directly in SQL to improve performances
99         cr.execute("""  SELECT m.res_id FROM mail_message m
100                         RIGHT JOIN mail_notification n
101                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
102                         WHERE m.model = %s AND m.res_id in %s""",
103                     (user_pid, self._name, tuple(ids),))
104         msg_ids = [result[0] for result in cr.fetchall()]
105         for msg_id in msg_ids:
106             res[msg_id]['message_unread'] = True
107
108         for thread in self.browse(cr, uid, ids, context=context):
109             cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
110             res[thread.id]['message_summary'] = "<span%s><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (cls, len(thread.message_ids), len(thread.message_follower_ids))
111
112         return res
113
114     def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
115         """ Computes:
116             - message_subtype_data: data about document subtypes: which are
117                 available, which are followed if any """
118         res = dict((id, dict(message_subtype_data='')) for id in ids)
119         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
120
121         # find current model subtypes, add them to a dictionary
122         subtype_obj = self.pool.get('mail.message.subtype')
123         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
124         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))
125         for id in ids:
126             res[id]['message_subtype_data'] = subtype_dict.copy()
127
128         # find the document followers, update the data
129         fol_obj = self.pool.get('mail.followers')
130         fol_ids = fol_obj.search(cr, uid, [
131             ('partner_id', '=', user_pid),
132             ('res_id', 'in', ids),
133             ('res_model', '=', self._name),
134         ], context=context)
135         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
136             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
137             for subtype in fol.subtype_ids:
138                 thread_subtype_dict[subtype.name]['followed'] = True
139             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
140
141         return res
142
143     def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
144         return [('message_ids.to_read', '=', True)]
145
146     def _get_followers(self, cr, uid, ids, name, arg, context=None):
147         fol_obj = self.pool.get('mail.followers')
148         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
149         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
150         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
151         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
152             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
153             if fol.partner_id.id == user_pid:
154                 res[fol.res_id]['message_is_follower'] = True
155         return res
156
157     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
158         if not value:
159             return
160         partner_obj = self.pool.get('res.partner')
161         fol_obj = self.pool.get('mail.followers')
162
163         # read the old set of followers, and determine the new set of followers
164         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
165         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
166         new = set(old)
167
168         for command in value or []:
169             if isinstance(command, (int, long)):
170                 new.add(command)
171             elif command[0] == 0:
172                 new.add(partner_obj.create(cr, uid, command[2], context=context))
173             elif command[0] == 1:
174                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
175                 new.add(command[1])
176             elif command[0] == 2:
177                 partner_obj.unlink(cr, uid, [command[1]], context=context)
178                 new.discard(command[1])
179             elif command[0] == 3:
180                 new.discard(command[1])
181             elif command[0] == 4:
182                 new.add(command[1])
183             elif command[0] == 5:
184                 new.clear()
185             elif command[0] == 6:
186                 new = set(command[2])
187
188         # remove partners that are no longer followers
189         fol_ids = fol_obj.search(cr, SUPERUSER_ID,
190             [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
191         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
192
193         # add new followers
194         for partner_id in new - old:
195             fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
196
197     def _search_followers(self, cr, uid, obj, name, args, context):
198         fol_obj = self.pool.get('mail.followers')
199         res = []
200         for field, operator, value in args:
201             assert field == name
202             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
203             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
204             res.append(('id', 'in', res_ids))
205         return res
206
207     _columns = {
208         'message_is_follower': fields.function(_get_followers,
209             type='boolean', string='Is a Follower', multi='_get_followers,'),
210         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
211                 fnct_search=_search_followers, type='many2many',
212                 obj='res.partner', string='Followers', multi='_get_followers'),
213         'message_ids': fields.one2many('mail.message', 'res_id',
214             domain=lambda self: [('model', '=', self._name)],
215             auto_join=True,
216             string='Messages',
217             help="Messages and communication history"),
218         'message_unread': fields.function(_get_message_data,
219             fnct_search=_search_message_unread, multi="_get_message_data",
220             type='boolean', string='Unread Messages',
221             help="If checked new messages require your attention."),
222         'message_summary': fields.function(_get_message_data, method=True,
223             type='text', string='Summary', multi="_get_message_data",
224             help="Holds the Chatter summary (number of messages, ...). "\
225                  "This summary is directly in html format in order to "\
226                  "be inserted in kanban views."),
227     }
228
229     #------------------------------------------------------
230     # CRUD overrides for automatic subscription and logging
231     #------------------------------------------------------
232
233     def create(self, cr, uid, values, context=None):
234         """ Chatter override :
235             - subscribe uid
236             - subscribe followers of parent
237             - log a creation message
238         """
239         if context is None:
240             context = {}
241         thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
242
243         # subscribe uid unless asked not to
244         if not context.get('mail_create_nosubscribe'):
245             self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
246         self.message_auto_subscribe(cr, uid, [thread_id], values.keys(), context=context)
247
248         # automatic logging unless asked not to (mainly for various testing purpose)
249         if not context.get('mail_create_nolog'):
250             self.message_post(cr, uid, thread_id, body='Document created', context=context)
251         return thread_id
252
253     def write(self, cr, uid, ids, values, context=None):
254         if isinstance(ids, (int, long)):
255             ids = [ids]
256         # Track initial values of tracked fields
257         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
258         if tracked_fields:
259             initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
260             initial_values = dict((item['id'], item) for item in initial)
261
262         # Perform write, update followers
263         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
264         self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
265
266         # Perform the tracking
267         if tracked_fields:
268             self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
269         return result
270
271     def unlink(self, cr, uid, ids, context=None):
272         """ Override unlink to delete messages and followers. This cannot be
273             cascaded, because link is done through (res_model, res_id). """
274         msg_obj = self.pool.get('mail.message')
275         fol_obj = self.pool.get('mail.followers')
276         # delete messages and notifications
277         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
278         msg_obj.unlink(cr, uid, msg_ids, context=context)
279         # delete
280         res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
281         # delete followers
282         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
283         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
284         return res
285
286     def copy(self, cr, uid, id, default=None, context=None):
287         default = default or {}
288         default['message_ids'] = []
289         default['message_follower_ids'] = []
290         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
291
292     #------------------------------------------------------
293     # Automatically log tracked fields
294     #------------------------------------------------------
295
296     def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
297         """ Return a structure of tracked fields for the current model.
298             :param list updated_fields: modified field names
299             :return list: a list of (field_name, column_info obj), containing
300                 always tracked fields and modified on_change fields
301         """
302         lst = []
303         for name, column_info in self._all_columns.items():
304             visibility = getattr(column_info.column, 'track_visibility', False)
305             if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
306                 lst.append(name)
307         if not lst:
308             return lst
309         return self.fields_get(cr, uid, lst, context=context)
310
311     def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
312
313         def convert_for_display(value, col_info):
314             if not value and col_info['type'] == 'boolean':
315                 return 'False'
316             if not value:
317                 return ''
318             if col_info['type'] == 'many2one':
319                 return value[1]
320             if col_info['type'] == 'selection':
321                 return dict(col_info['selection'])[value]
322             return value
323
324         def format_message(message_description, tracked_values):
325             message = ''
326             if message_description:
327                 message = '<span>%s</span>' % message_description
328             for name, change in tracked_values.items():
329                 message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % change.get('col_info')
330                 if change.get('old_value'):
331                     message += '%s &rarr; ' % change.get('old_value')
332                 message += '%s</div>' % change.get('new_value')
333             return message
334
335         if not tracked_fields:
336             return True
337
338         for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
339             initial = initial_values[record['id']]
340             changes = []
341             tracked_values = {}
342
343             # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
344             for col_name, col_info in tracked_fields.items():
345                 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
346                     tracked_values[col_name] = dict(col_info=col_info['string'],
347                                                         new_value=convert_for_display(record[col_name], col_info))
348                 elif record[col_name] != initial[col_name]:
349                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
350                         tracked_values[col_name] = dict(col_info=col_info['string'],
351                                                             old_value=convert_for_display(initial[col_name], col_info),
352                                                             new_value=convert_for_display(record[col_name], col_info))
353                     if col_name in tracked_fields:
354                         changes.append(col_name)
355             if not changes:
356                 continue
357
358             # find subtypes and post messages or log if no subtype found
359             subtypes = []
360             for field, track_info in self._track.items():
361                 if field not in changes:
362                     continue
363                 for subtype, method in track_info.items():
364                     if method(self, cr, uid, record, context):
365                         subtypes.append(subtype)
366
367             posted = False
368             for subtype in subtypes:
369                 try:
370                     subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1])
371                 except ValueError, e:
372                     _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
373                     continue
374                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
375                 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
376                 posted = True
377             if not posted:
378                 message = format_message('', tracked_values)
379                 self.message_post(cr, uid, record['id'], body=message, context=context)
380         return True
381
382     #------------------------------------------------------
383     # mail.message wrappers and tools
384     #------------------------------------------------------
385
386     def _needaction_domain_get(self, cr, uid, context=None):
387         if self._needaction:
388             return [('message_unread', '=', True)]
389         return []
390
391     #------------------------------------------------------
392     # Email specific
393     #------------------------------------------------------
394
395     def message_get_reply_to(self, cr, uid, ids, context=None):
396         if not self._inherits.get('mail.alias'):
397             return [False for id in ids]
398         return ["%s@%s" % (record['alias_name'], record['alias_domain'])
399                     if record.get('alias_domain') and record.get('alias_name')
400                     else False
401                     for record in self.read(cr, uid, ids, ['alias_name', 'alias_domain'], context=context)]
402
403     #------------------------------------------------------
404     # Mail gateway
405     #------------------------------------------------------
406
407     def message_capable_models(self, cr, uid, context=None):
408         """ Used by the plugin addon, based for plugin_outlook and others. """
409         ret_dict = {}
410         for model_name in self.pool.obj_list():
411             model = self.pool.get(model_name)
412             if 'mail.thread' in getattr(model, '_inherit', []):
413                 ret_dict[model_name] = model._description
414         return ret_dict
415
416     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
417         """ Find partners related to some header fields of the message. """
418         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
419         return [partner_id for email in tools.email_split(s)
420                 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], limit=1, context=context)]
421
422     def _message_find_user_id(self, cr, uid, message, context=None):
423         from_local_part = tools.email_split(decode(message.get('From')))[0]
424         # FP Note: canonification required, the minimu: .lower()
425         user_ids = self.pool.get('res.users').search(cr, uid, ['|',
426             ('login', '=', from_local_part),
427             ('email', '=', from_local_part)], context=context)
428         return user_ids[0] if user_ids else uid
429
430     def message_route(self, cr, uid, message, model=None, thread_id=None,
431                       custom_values=None, context=None):
432         """Attempt to figure out the correct target model, thread_id,
433         custom_values and user_id to use for an incoming message.
434         Multiple values may be returned, if a message had multiple
435         recipients matching existing mail.aliases, for example.
436
437         The following heuristics are used, in this order:
438              1. If the message replies to an existing thread_id, and
439                 properly contains the thread model in the 'In-Reply-To'
440                 header, use this model/thread_id pair, and ignore
441                 custom_value (not needed as no creation will take place)
442              2. Look for a mail.alias entry matching the message
443                 recipient, and use the corresponding model, thread_id,
444                 custom_values and user_id.
445              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
446                 provided.
447              4. If all the above fails, raise an exception.
448
449            :param string message: an email.message instance
450            :param string model: the fallback model to use if the message
451                does not match any of the currently configured mail aliases
452                (may be None if a matching alias is supposed to be present)
453            :type dict custom_values: optional dictionary of default field values
454                 to pass to ``message_new`` if a new record needs to be created.
455                 Ignored if the thread record already exists, and also if a
456                 matching mail.alias was found (aliases define their own defaults)
457            :param int thread_id: optional ID of the record/thread from ``model``
458                to which this mail should be attached. Only used if the message
459                does not reply to an existing thread and does not match any mail alias.
460            :return: list of [model, thread_id, custom_values, user_id]
461         """
462         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
463         message_id = message.get('Message-Id')
464         references = decode_header(message, 'References')
465         in_reply_to = decode_header(message, 'In-Reply-To')
466
467         # 1. Verify if this is a reply to an existing thread
468         thread_references = references or in_reply_to
469         ref_match = thread_references and tools.reference_re.search(thread_references)
470         if ref_match:
471             thread_id = int(ref_match.group(1))
472             model = ref_match.group(2) or model
473             model_pool = self.pool.get(model)
474             if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
475                 and hasattr(model_pool, 'message_update'):
476                 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
477                               message_id, model, thread_id, custom_values, uid)
478                 return [(model, thread_id, custom_values, uid)]
479
480         # Verify whether this is a reply to a private message
481         if in_reply_to:
482             message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
483             if message_ids:
484                 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
485                 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
486                                 message_id, message.id, custom_values, uid)
487                 return [(message.model, message.res_id, custom_values, uid)]
488
489         # 2. Look for a matching mail.alias entry
490         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
491         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
492         rcpt_tos = \
493              ','.join([decode_header(message, 'Delivered-To'),
494                        decode_header(message, 'To'),
495                        decode_header(message, 'Cc'),
496                        decode_header(message, 'Resent-To'),
497                        decode_header(message, 'Resent-Cc')])
498         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
499         if local_parts:
500             mail_alias = self.pool.get('mail.alias')
501             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
502             if alias_ids:
503                 routes = []
504                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
505                     user_id = alias.alias_user_id.id
506                     if not user_id:
507                         # TDE note: this could cause crashes, because no clue that the user
508                         # that send the email has the right to create or modify a new document
509                         # Fallback on user_id = uid
510                         # Note: recognized partners will be added as followers anyway
511                         # user_id = self._message_find_user_id(cr, uid, message, context=context)
512                         user_id = uid
513                         _logger.debug('No matching user_id for the alias %s', alias.alias_name)
514                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
515                                    eval(alias.alias_defaults), user_id))
516                 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
517                 return routes
518
519         # 3. Fallback to the provided parameters, if they work
520         model_pool = self.pool.get(model)
521         if not thread_id:
522             # Legacy: fallback to matching [ID] in the Subject
523             match = tools.res_re.search(decode_header(message, 'Subject'))
524             thread_id = match and match.group(1)
525         assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
526             "No possible route found for incoming message with Message-Id %s. " \
527             "Create an appropriate mail.alias or force the destination model." % message_id
528         if thread_id and not model_pool.exists(cr, uid, thread_id):
529             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
530                             thread_id, message_id)
531             thread_id = None
532         _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
533                       message_id, model, thread_id, custom_values, uid)
534         return [(model, thread_id, custom_values, uid)]
535
536     def message_process(self, cr, uid, model, message, custom_values=None,
537                         save_original=False, strip_attachments=False,
538                         thread_id=None, context=None):
539         """ Process an incoming RFC2822 email message, relying on
540             ``mail.message.parse()`` for the parsing operation,
541             and ``message_route()`` to figure out the target model.
542
543             Once the target model is known, its ``message_new`` method
544             is called with the new message (if the thread record did not exist)
545             or its ``message_update`` method (if it did).
546
547             There is a special case where the target model is False: a reply
548             to a private message. In this case, we skip the message_new /
549             message_update step, to just post a new message using mail_thread
550             message_post.
551
552            :param string model: the fallback model to use if the message
553                does not match any of the currently configured mail aliases
554                (may be None if a matching alias is supposed to be present)
555            :param message: source of the RFC2822 message
556            :type message: string or xmlrpclib.Binary
557            :type dict custom_values: optional dictionary of field values
558                 to pass to ``message_new`` if a new record needs to be created.
559                 Ignored if the thread record already exists, and also if a
560                 matching mail.alias was found (aliases define their own defaults)
561            :param bool save_original: whether to keep a copy of the original
562                 email source attached to the message after it is imported.
563            :param bool strip_attachments: whether to strip all attachments
564                 before processing the message, in order to save some space.
565            :param int thread_id: optional ID of the record/thread from ``model``
566                to which this mail should be attached. When provided, this
567                overrides the automatic detection based on the message
568                headers.
569         """
570         if context is None:
571             context = {}
572
573         # extract message bytes - we are forced to pass the message as binary because
574         # we don't know its encoding until we parse its headers and hence can't
575         # convert it to utf-8 for transport between the mailgate script and here.
576         if isinstance(message, xmlrpclib.Binary):
577             message = str(message.data)
578         # Warning: message_from_string doesn't always work correctly on unicode,
579         # we must use utf-8 strings here :-(
580         if isinstance(message, unicode):
581             message = message.encode('utf-8')
582         msg_txt = email.message_from_string(message)
583         routes = self.message_route(cr, uid, msg_txt, model,
584                                     thread_id, custom_values,
585                                     context=context)
586         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
587         if strip_attachments:
588             msg.pop('attachments', None)
589
590         # postpone setting msg.partner_ids after message_post, to avoid double notifications
591         partner_ids = msg.pop('partner_ids', [])
592
593         thread_id = False
594         for model, thread_id, custom_values, user_id in routes:
595             if self._name != model:
596                 context.update({'thread_model': model})
597             if model:
598                 model_pool = self.pool.get(model)
599                 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
600                     "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
601                         (msg['message_id'], model)
602
603                 # disabled subscriptions during message_new/update to avoid having the system user running the
604                 # email gateway become a follower of all inbound messages
605                 nosub_ctx = dict(context, mail_create_nosubscribe=True)
606                 if thread_id and hasattr(model_pool, 'message_update'):
607                     model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
608                 else:
609                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
610             else:
611                 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
612                 model_pool = self.pool.get('mail.thread')
613             new_msg_id = model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
614
615             # when posting an incoming email to a document: subscribe the author, if a partner, as follower
616             if model and thread_id and msg.get('author_id'):
617                 model_pool.message_subscribe(cr, uid, [thread_id], [msg.get('author_id')], context=context)
618
619             if partner_ids:
620                 # postponed after message_post, because this is an external message and we don't want to create
621                 # duplicate emails due to notifications
622                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
623
624         return thread_id
625
626     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
627         """Called by ``message_process`` when a new message is received
628            for a given thread model, if the message did not belong to
629            an existing thread.
630            The default behavior is to create a new record of the corresponding
631            model (based on some very basic info extracted from the message).
632            Additional behavior may be implemented by overriding this method.
633
634            :param dict msg_dict: a map containing the email details and
635                                  attachments. See ``message_process`` and
636                                 ``mail.message.parse`` for details.
637            :param dict custom_values: optional dictionary of additional
638                                       field values to pass to create()
639                                       when creating the new thread record.
640                                       Be careful, these values may override
641                                       any other values coming from the message.
642            :param dict context: if a ``thread_model`` value is present
643                                 in the context, its value will be used
644                                 to determine the model of the record
645                                 to create (instead of the current model).
646            :rtype: int
647            :return: the id of the newly created thread object
648         """
649         if context is None:
650             context = {}
651         data = {}
652         if isinstance(custom_values, dict):
653             data = custom_values.copy()
654         model = context.get('thread_model') or self._name
655         model_pool = self.pool.get(model)
656         fields = model_pool.fields_get(cr, uid, context=context)
657         if 'name' in fields and not data.get('name'):
658             data['name'] = msg_dict.get('subject', '')
659         res_id = model_pool.create(cr, uid, data, context=context)
660         return res_id
661
662     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
663         """Called by ``message_process`` when a new message is received
664            for an existing thread. The default behavior is to update the record
665            with update_vals taken from the incoming email.
666            Additional behavior may be implemented by overriding this
667            method.
668            :param dict msg_dict: a map containing the email details and
669                                attachments. See ``message_process`` and
670                                ``mail.message.parse()`` for details.
671            :param dict update_vals: a dict containing values to update records
672                               given their ids; if the dict is None or is
673                               void, no write operation is performed.
674         """
675         if update_vals:
676             self.write(cr, uid, ids, update_vals, context=context)
677         return True
678
679     def _message_extract_payload(self, message, save_original=False):
680         """Extract body as HTML and attachments from the mail message"""
681         attachments = []
682         body = u''
683         if save_original:
684             attachments.append(('original_email.eml', message.as_string()))
685         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
686             encoding = message.get_content_charset()
687             body = message.get_payload(decode=True)
688             body = tools.ustr(body, encoding, errors='replace')
689             if message.get_content_type() == 'text/plain':
690                 # text/plain -> <pre/>
691                 body = tools.append_content_to_html(u'', body, preserve=True)
692         else:
693             alternative = (message.get_content_type() == 'multipart/alternative')
694             for part in message.walk():
695                 if part.get_content_maintype() == 'multipart':
696                     continue  # skip container
697                 filename = part.get_filename()  # None if normal part
698                 encoding = part.get_content_charset()  # None if attachment
699                 # 1) Explicit Attachments -> attachments
700                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
701                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
702                     continue
703                 # 2) text/plain -> <pre/>
704                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
705                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
706                                                                          encoding, errors='replace'), preserve=True)
707                 # 3) text/html -> raw
708                 elif part.get_content_type() == 'text/html':
709                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
710                     if alternative:
711                         body = html
712                     else:
713                         body = tools.append_content_to_html(body, html, plaintext=False)
714                 # 4) Anything else -> attachment
715                 else:
716                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
717         return body, attachments
718
719     def message_parse(self, cr, uid, message, save_original=False, context=None):
720         """Parses a string or email.message.Message representing an
721            RFC-2822 email, and returns a generic dict holding the
722            message details.
723
724            :param message: the message to parse
725            :type message: email.message.Message | string | unicode
726            :param bool save_original: whether the returned dict
727                should include an ``original`` attachment containing
728                the source of the message
729            :rtype: dict
730            :return: A dict with the following structure, where each
731                     field may not be present if missing in original
732                     message::
733
734                     { 'message_id': msg_id,
735                       'subject': subject,
736                       'from': from,
737                       'to': to,
738                       'cc': cc,
739                       'body': unified_body,
740                       'attachments': [('file1', 'bytes'),
741                                       ('file2', 'bytes')}
742                     }
743         """
744         msg_dict = {
745             'type': 'email',
746             'author_id': False,
747         }
748         if not isinstance(message, Message):
749             if isinstance(message, unicode):
750                 # Warning: message_from_string doesn't always work correctly on unicode,
751                 # we must use utf-8 strings here :-(
752                 message = message.encode('utf-8')
753             message = email.message_from_string(message)
754
755         message_id = message['message-id']
756         if not message_id:
757             # Very unusual situation, be we should be fault-tolerant here
758             message_id = "<%s@localhost>" % time.time()
759             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
760         msg_dict['message_id'] = message_id
761
762         if 'Subject' in message:
763             msg_dict['subject'] = decode(message.get('Subject'))
764
765         # Envelope fields not stored in mail.message but made available for message_new()
766         msg_dict['from'] = decode(message.get('from'))
767         msg_dict['to'] = decode(message.get('to'))
768         msg_dict['cc'] = decode(message.get('cc'))
769
770         if 'From' in message:
771             author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
772             if author_ids:
773                 msg_dict['author_id'] = author_ids[0]
774             else:
775                 msg_dict['email_from'] = message.get('from')
776         partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
777         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
778
779         if 'Date' in message:
780             try:
781                 date_hdr = decode(message.get('Date'))
782                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
783                 if parsed_date.utcoffset() is None:
784                     # naive datetime, so we arbitrarily decide to make it
785                     # UTC, there's no better choice. Should not happen,
786                     # as RFC2822 requires timezone offset in Date headers.
787                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
788                 else:
789                     stored_date = parsed_date.astimezone(pytz.utc)
790             except Exception:
791                 _logger.warning('Failed to parse Date header %r in incoming mail '
792                                 'with message-id %r, assuming current date/time.',
793                                 message.get('Date'), message_id)
794                 stored_date = datetime.datetime.now()
795             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
796
797         if 'In-Reply-To' in message:
798             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
799             if parent_ids:
800                 msg_dict['parent_id'] = parent_ids[0]
801
802         if 'References' in message and 'parent_id' not in msg_dict:
803             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
804                                                                          [x.strip() for x in decode(message['References']).split()])])
805             if parent_ids:
806                 msg_dict['parent_id'] = parent_ids[0]
807
808         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
809         return msg_dict
810
811     #------------------------------------------------------
812     # Note specific
813     #------------------------------------------------------
814
815     def log(self, cr, uid, id, message, secondary=False, context=None):
816         _logger.warning("log() is deprecated. As this module inherit from "\
817                         "mail.thread, the message will be managed by this "\
818                         "module instead of by the res.log mechanism. Please "\
819                         "use mail_thread.message_post() instead of the "\
820                         "now deprecated res.log.")
821         self.message_post(cr, uid, [id], message, context=context)
822
823     def message_create_partners_from_emails(self, cr, uid, emails, context=None):
824         """ Convert a list of emails into a list partner_ids and a list
825             new_partner_ids. The return value is non conventional because
826             it is meant to be used by the mail widget.
827
828             :return dict: partner_ids and new_partner_ids
829         """
830         partner_obj = self.pool.get('res.partner')
831         mail_message_obj = self.pool.get('mail.message')
832
833         partner_ids = []
834         new_partner_ids = []
835         for email in emails:
836             m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
837             name = m.group(2) or m.group(0)
838             email = m.group(3)
839             ids = partner_obj.search(cr, SUPERUSER_ID, [('email', '=', email)], context=context)
840             if ids:
841                 partner_ids.append(ids[0])
842                 partner_id = ids[0]
843             else:
844                 partner_id = partner_obj.create(cr, uid, {
845                         'name': name or email,
846                         'email': email,
847                     }, context=context)
848                 new_partner_ids.append(partner_id)
849
850             # link mail with this from mail to the new partner id
851             message_ids = mail_message_obj.search(cr, SUPERUSER_ID, ['|', ('email_from', '=', email), ('email_from', 'ilike', '<%s>' % email), ('author_id', '=', False)], context=context)
852             if message_ids:
853                 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'email_from': None, 'author_id': partner_id}, context=context)
854         return {
855             'partner_ids': partner_ids,
856             'new_partner_ids': new_partner_ids,
857         }
858
859     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
860                         subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
861         """ Post a new message in an existing thread, returning the new
862             mail.message ID. Extra keyword arguments will be used as default
863             column values for the new mail.message record.
864             Auto link messages for same id and object
865             :param int thread_id: thread ID to post into, or list with one ID;
866                 if False/0, mail.message model will also be set as False
867             :param str body: body of the message, usually raw HTML that will
868                 be sanitized
869             :param str subject: optional subject
870             :param str type: mail_message.type
871             :param int parent_id: optional ID of parent message in this thread
872             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
873                 ``(name,content)``, where content is NOT base64 encoded
874             :return: ID of newly created mail.message
875         """
876         if context is None:
877             context = {}
878         if attachments is None:
879             attachments = {}
880
881         assert (not thread_id) or isinstance(thread_id, (int, long)) or \
882             (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), "Invalid thread_id; should be 0, False, an ID or a list with one ID"
883         if isinstance(thread_id, (list, tuple)):
884             thread_id = thread_id and thread_id[0]
885         mail_message = self.pool.get('mail.message')
886         model = context.get('thread_model', self._name) if thread_id else False
887
888         attachment_ids = kwargs.pop('attachment_ids', [])
889         for name, content in attachments:
890             if isinstance(content, unicode):
891                 content = content.encode('utf-8')
892             data_attach = {
893                 'name': name,
894                 'datas': base64.b64encode(str(content)),
895                 'datas_fname': name,
896                 'description': name,
897                 'res_model': context.get('thread_model') or self._name,
898                 'res_id': thread_id,
899             }
900             attachment_ids.append((0, 0, data_attach))
901
902         # fetch subtype
903         if subtype:
904             s_data = subtype.split('.')
905             if len(s_data) == 1:
906                 s_data = ('mail', s_data[0])
907             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
908             subtype_id = ref and ref[1] or False
909         else:
910             subtype_id = False
911
912         # _mail_flat_thread: automatically set free messages to the first posted message
913         if self._mail_flat_thread and not parent_id and thread_id:
914             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
915             parent_id = message_ids and message_ids[0] or False
916         # 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
917         elif parent_id:
918             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
919             # avoid loops when finding ancestors
920             processed_list = []
921             if message_ids:
922                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
923                 while (message.parent_id and message.parent_id.id not in processed_list):
924                     processed_list.append(message.parent_id.id)
925                     message = message.parent_id
926                 parent_id = message.id
927
928         values = kwargs
929         values.update({
930             'model': model,
931             'res_id': thread_id or False,
932             'body': body,
933             'subject': subject or False,
934             'type': type,
935             'parent_id': parent_id,
936             'attachment_ids': attachment_ids,
937             'subtype_id': subtype_id,
938         })
939
940         # Avoid warnings about non-existing fields
941         for x in ('from', 'to', 'cc'):
942             values.pop(x, None)
943
944         return mail_message.create(cr, uid, values, context=context)
945
946     def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
947                                 attachment_ids=None, content_subtype='plaintext',
948                                 context=None, **kwargs):
949         """ Wrapper on message_post, used for user input :
950             - mail gateway
951             - quick reply in Chatter (refer to mail.js), not
952                 the mail.compose.message wizard
953             The purpose is to perform some pre- and post-processing:
954             - if body is plaintext: convert it into html
955             - if parent_id: handle reply to a previous message by adding the
956                 parent partners to the message
957             - type and subtype: comment and mail.mt_comment by default
958             - attachment_ids: supposed not attached to any document; attach them
959                 to the related document. Should only be set by Chatter.
960         """
961         mail_message_obj = self.pool.get('mail.message')
962         ir_attachment = self.pool.get('ir.attachment')
963
964         # 1.A.1: add recipients of parent message (# TDE FIXME HACK: mail.thread -> private message)
965         partner_ids = set([])
966         if parent_id and self._name == 'mail.thread':
967             parent_message = mail_message_obj.browse(cr, uid, parent_id, context=context)
968             partner_ids |= set([(4, partner.id) for partner in parent_message.partner_ids])
969             if parent_message.author_id.id:
970                 partner_ids.add((4, parent_message.author_id.id))
971
972         # 1.A.2: add specified recipients
973         param_partner_ids = set()
974         for item in kwargs.pop('partner_ids', []):
975             if isinstance(item, (list)):
976                 param_partner_ids.add((item[0], item[1]))
977             elif isinstance(item, (int, long)):
978                 param_partner_ids.add((4, item))
979             else:
980                 param_partner_ids.add(item)
981         partner_ids |= param_partner_ids
982
983         # 1.A.3: add parameters recipients as follower
984         # TDE FIXME in 7.1: should check whether this comes from email_list or partner_ids
985         if param_partner_ids and self._name != 'mail.thread':
986             self.message_subscribe(cr, uid, [thread_id], [pid[1] for pid in param_partner_ids], context=context)
987
988         # 1.B: handle body, message_type and message_subtype
989         if content_subtype == 'plaintext':
990             body = tools.plaintext2html(body)
991         msg_type = kwargs.pop('type', 'comment')
992         msg_subtype = kwargs.pop('subtype', 'mail.mt_comment')
993
994         # 2. Pre-processing: attachments
995         # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
996         if attachment_ids:
997             # TDE FIXME (?): when posting a private message, we use mail.thread as a model
998             # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
999             model = self._name
1000             if model == 'mail.thread':
1001                 model = False
1002             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1003                 ('res_model', '=', 'mail.compose.message'),
1004                 ('res_id', '=', 0),
1005                 ('create_uid', '=', uid),
1006                 ('id', 'in', attachment_ids)], context=context)
1007             if filtered_attachment_ids:
1008                 if thread_id and model:
1009                     ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1010         else:
1011             attachment_ids = []
1012         attachment_ids = [(4, id) for id in attachment_ids]
1013
1014         # 3. Post message
1015         return self.message_post(cr, uid, thread_id=thread_id, body=body,
1016                             type=msg_type, subtype=msg_subtype, parent_id=parent_id,
1017                             attachment_ids=attachment_ids, partner_ids=list(partner_ids), context=context, **kwargs)
1018
1019     #------------------------------------------------------
1020     # Followers API
1021     #------------------------------------------------------
1022
1023     def message_get_subscription_data(self, cr, uid, ids, context=None):
1024         """ Wrapper to get subtypes data. """
1025         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1026
1027     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1028         """ Wrapper on message_subscribe, using users. If user_ids is not
1029             provided, subscribe uid instead. """
1030         if user_ids is None:
1031             user_ids = [uid]
1032         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1033         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1034
1035     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1036         """ Add partners to the records followers. """
1037         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1038         if set(partner_ids) == set([user_pid]):
1039             self.check_access_rights(cr, uid, 'read')
1040         else:
1041             self.check_access_rights(cr, uid, 'write')
1042
1043         self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1044         # if subtypes are not specified (and not set to a void list), fetch default ones
1045         if subtype_ids is None:
1046             subtype_obj = self.pool.get('mail.message.subtype')
1047             subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1048         # update the subscriptions
1049         fol_obj = self.pool.get('mail.followers')
1050         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1051         fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1052         return True
1053
1054     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1055         """ Wrapper on message_subscribe, using users. If user_ids is not
1056             provided, unsubscribe uid instead. """
1057         if user_ids is None:
1058             user_ids = [uid]
1059         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1060         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1061
1062     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1063         """ Remove partners from the records followers. """
1064         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1065         if set(partner_ids) == set([user_pid]):
1066             self.check_access_rights(cr, uid, 'read')
1067         else:
1068             self.check_access_rights(cr, uid, 'write')
1069         return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1070
1071     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1072         """ Returns the list of relational fields linking to res.users that should
1073             trigger an auto subscribe. The default list checks for the fields
1074             - called 'user_id'
1075             - linking to res.users
1076             - with track_visibility set
1077             In OpenERP V7, this is sufficent for all major addon such as opportunity,
1078             project, issue, recruitment, sale.
1079             Override this method if a custom behavior is needed about fields
1080             that automatically subscribe users.
1081         """
1082         user_field_lst = []
1083         for name, column_info in self._all_columns.items():
1084             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':
1085                 user_field_lst.append(name)
1086         return user_field_lst
1087
1088     def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1089         """
1090             1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1091             2. for each project subtype: subscribe the follower to the task
1092         """
1093         subtype_obj = self.pool.get('mail.message.subtype')
1094         follower_obj = self.pool.get('mail.followers')
1095
1096         # fetch auto_follow_fields
1097         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1098
1099         # fetch related record subtypes
1100         related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1101         subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1102         default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1103         related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1104         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1105         if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1106             return True
1107
1108         for record in self.browse(cr, uid, ids, context=context):
1109             new_followers = dict()
1110             parent_res_id = False
1111             parent_model = False
1112             for subtype in related_subtypes:
1113                 if not subtype.relation_field or not subtype.parent_id:
1114                     continue
1115                 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1116                     continue
1117                 parent_res_id = getattr(record, subtype.relation_field).id
1118                 parent_model = subtype.res_model
1119                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1120                     ('res_model', '=', parent_model),
1121                     ('res_id', '=', parent_res_id),
1122                     ('subtype_ids', 'in', [subtype.id])
1123                     ], context=context)
1124                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1125                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1126
1127             if parent_res_id and parent_model:
1128                 for subtype in default_subtypes:
1129                     follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1130                         ('res_model', '=', parent_model),
1131                         ('res_id', '=', parent_res_id),
1132                         ('subtype_ids', 'in', [subtype.id])
1133                         ], context=context)
1134                     for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1135                         new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1136
1137             # add followers coming from res.users relational fields that are tracked
1138             user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1139             for partner_id in [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]:
1140                 new_followers.setdefault(partner_id, None)
1141
1142             for pid, subtypes in new_followers.items():
1143                 subtypes = list(subtypes) if subtypes is not None else None
1144                 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1145         return True
1146
1147     #------------------------------------------------------
1148     # Thread state
1149     #------------------------------------------------------
1150
1151     def message_mark_as_unread(self, cr, uid, ids, context=None):
1152         """ Set as unread. """
1153         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1154         cr.execute('''
1155             UPDATE mail_notification SET
1156                 read=false
1157             WHERE
1158                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1159                 partner_id = %s
1160         ''', (ids, self._name, partner_id))
1161         return True
1162
1163     def message_mark_as_read(self, cr, uid, ids, context=None):
1164         """ Set as read. """
1165         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1166         cr.execute('''
1167             UPDATE mail_notification SET
1168                 read=true
1169             WHERE
1170                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1171                 partner_id = %s
1172         ''', (ids, self._name, partner_id))
1173         return True
1174
1175 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: