[CLEAN] Some code cleaning.
[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_subscribe_from_parent(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_subscribe_from_parent(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         model = context.get('thread_model') or self._name
652         model_pool = self.pool.get(model)
653         fields = model_pool.fields_get(cr, uid, context=context)
654         data = model_pool.default_get(cr, uid, fields, context=context)
655         if 'name' in fields and not data.get('name'):
656             data['name'] = msg_dict.get('subject', '')
657         if custom_values and isinstance(custom_values, dict):
658             data.update(custom_values)
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, ['From', '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_get_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             else:
843                 partner_id = partner_obj.create(cr, uid, {
844                         'name': name or email,
845                         'email': email,
846                     }, context=context)
847                 new_partner_ids.append(partner_id)
848
849             # link mail with this from mail to the new partner id
850             message_ids = mail_message_obj.search(cr, SUPERUSER_ID, ['|', ('email_from', '=', email), ('email_from', 'ilike', '<%s>' % email), ('author_id', '=', False)], context=context)
851             if message_ids:
852                 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'email_from': None, 'author_id': partner_id}, context=context)
853         return {
854             'partner_ids': partner_ids,
855             'new_partner_ids': new_partner_ids,
856         }
857
858     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
859                         subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
860         """ Post a new message in an existing thread, returning the new
861             mail.message ID. Extra keyword arguments will be used as default
862             column values for the new mail.message record.
863             Auto link messages for same id and object
864             :param int thread_id: thread ID to post into, or list with one ID;
865                 if False/0, mail.message model will also be set as False
866             :param str body: body of the message, usually raw HTML that will
867                 be sanitized
868             :param str subject: optional subject
869             :param str type: mail_message.type
870             :param int parent_id: optional ID of parent message in this thread
871             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
872                 ``(name,content)``, where content is NOT base64 encoded
873             :return: ID of newly created mail.message
874         """
875         if context is None:
876             context = {}
877         if attachments is None:
878             attachments = {}
879
880         assert (not thread_id) or isinstance(thread_id, (int, long)) or \
881             (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"
882         if isinstance(thread_id, (list, tuple)):
883             thread_id = thread_id and thread_id[0]
884         mail_message = self.pool.get('mail.message')
885         model = context.get('thread_model', self._name) if thread_id else False
886
887         attachment_ids = kwargs.pop('attachment_ids', [])
888         for name, content in attachments:
889             if isinstance(content, unicode):
890                 content = content.encode('utf-8')
891             data_attach = {
892                 'name': name,
893                 'datas': base64.b64encode(str(content)),
894                 'datas_fname': name,
895                 'description': name,
896                 'res_model': context.get('thread_model') or self._name,
897                 'res_id': thread_id,
898             }
899             attachment_ids.append((0, 0, data_attach))
900
901         # fetch subtype
902         if subtype:
903             s_data = subtype.split('.')
904             if len(s_data) == 1:
905                 s_data = ('mail', s_data[0])
906             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
907             subtype_id = ref and ref[1] or False
908         else:
909             subtype_id = False
910
911         # _mail_flat_thread: automatically set free messages to the first posted message
912         if self._mail_flat_thread and not parent_id and thread_id:
913             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
914             parent_id = message_ids and message_ids[0] or False
915         # 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
916         elif parent_id:
917             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
918             # avoid loops when finding ancestors
919             processed_list = []
920             if message_ids:
921                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
922                 while (message.parent_id and message.parent_id.id not in processed_list):
923                     processed_list.append(message.parent_id.id)
924                     message = message.parent_id
925                 parent_id = message.id
926
927         values = kwargs
928         values.update({
929             'model': model,
930             'res_id': thread_id or False,
931             'body': body,
932             'subject': subject or False,
933             'type': type,
934             'parent_id': parent_id,
935             'attachment_ids': attachment_ids,
936             'subtype_id': subtype_id,
937         })
938
939         # Avoid warnings about non-existing fields
940         for x in ('from', 'to', 'cc'):
941             values.pop(x, None)
942
943         return mail_message.create(cr, uid, values, context=context)
944
945     def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
946                                 attachment_ids=None, content_subtype='plaintext',
947                                 context=None, **kwargs):
948         """ Wrapper on message_post, used for user input :
949             - mail gateway
950             - quick reply in Chatter (refer to mail.js), not
951                 the mail.compose.message wizard
952             The purpose is to perform some pre- and post-processing:
953             - if body is plaintext: convert it into html
954             - if parent_id: handle reply to a previous message by adding the
955                 parent partners to the message
956             - type and subtype: comment and mail.mt_comment by default
957             - attachment_ids: supposed not attached to any document; attach them
958                 to the related document. Should only be set by Chatter.
959             - extra_email: [ 'Fabien <fpi@openerp.com>', 'al@openerp.com' ]
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
965         partner_ids = set([])
966         if parent_id:
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             # TDE FIXME HACK: mail.thread -> private message
970             if self._name == 'mail.thread' and parent_message.author_id.id:
971                 partner_ids.add((4, parent_message.author_id.id))
972
973         # 1.A.2: add specified recipients
974         param_partner_ids = set()
975         for item in kwargs.pop('partner_ids', []):
976             if isinstance(item, (list)):
977                 param_partner_ids.add((item[0], item[1]))
978             elif isinstance(item, (int, long)):
979                 param_partner_ids.add((4, item))
980             else:
981                 param_partner_ids.add(item)
982         partner_ids |= param_partner_ids
983
984         # 1.A.3: add parameters recipients as follower
985         # TDE FIXME in 7.1: should check whether this comes from email_list or partner_ids
986         if param_partner_ids:
987             self.message_subscribe(cr, uid, [thread_id], [pid[1] for pid in param_partner_ids], context=context)
988
989         # 1.B: handle body, message_type and message_subtype
990         if content_subtype == 'plaintext':
991             body = tools.plaintext2html(body)
992         msg_type = kwargs.pop('type', 'comment')
993         msg_subtype = kwargs.pop('subtype', 'mail.mt_comment')
994
995         # 2. Pre-processing: attachments
996         # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
997         if attachment_ids:
998             # TDE FIXME (?): when posting a private message, we use mail.thread as a model
999             # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
1000             model = self._name
1001             if model == 'mail.thread':
1002                 model = False
1003             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1004                 ('res_model', '=', 'mail.compose.message'),
1005                 ('res_id', '=', 0),
1006                 ('create_uid', '=', uid),
1007                 ('id', 'in', attachment_ids)], context=context)
1008             if filtered_attachment_ids:
1009                 if thread_id and model:
1010                     ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1011         else:
1012             attachment_ids = []
1013         attachment_ids = [(4, id) for id in attachment_ids]
1014
1015         # 3. Post message
1016         return self.message_post(cr, uid, thread_id=thread_id, body=body,
1017                             type=msg_type, subtype=msg_subtype, parent_id=parent_id,
1018                             attachment_ids=attachment_ids, partner_ids=partner_ids, context=context, **kwargs)
1019
1020     #------------------------------------------------------
1021     # Followers API
1022     #------------------------------------------------------
1023
1024     def message_get_subscription_data(self, cr, uid, ids, context=None):
1025         """ Wrapper to get subtypes data. """
1026         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1027
1028     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1029         """ Wrapper on message_subscribe, using users. If user_ids is not
1030             provided, subscribe uid instead. """
1031         if user_ids is None:
1032             user_ids = [uid]
1033         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1034         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1035
1036     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1037         """ Add partners to the records followers. """
1038         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1039         if set(partner_ids) == set([user_pid]):
1040             self.check_access_rights(cr, uid, 'read')
1041         else:
1042             self.check_access_rights(cr, uid, 'write')
1043
1044         self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1045         # if subtypes are not specified (and not set to a void list), fetch default ones
1046         if subtype_ids is None:
1047             subtype_obj = self.pool.get('mail.message.subtype')
1048             subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1049         # update the subscriptions
1050         fol_obj = self.pool.get('mail.followers')
1051         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1052         fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1053         return True
1054
1055     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1056         """ Wrapper on message_subscribe, using users. If user_ids is not
1057             provided, unsubscribe uid instead. """
1058         if user_ids is None:
1059             user_ids = [uid]
1060         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1061         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1062
1063     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1064         """ Remove partners from the records followers. """
1065         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1066         if set(partner_ids) == set([user_pid]):
1067             self.check_access_rights(cr, uid, 'read')
1068         else:
1069             self.check_access_rights(cr, uid, 'write')
1070         return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1071
1072     def message_subscribe_from_parent(self, cr, uid, ids, updated_fields, context=None):
1073         """
1074             1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1075             2. for each project subtype: subscribe the follower to the task
1076         """
1077         subtype_obj = self.pool.get('mail.message.subtype')
1078         follower_obj = self.pool.get('mail.followers')
1079
1080         # fetch related record subtypes
1081         related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1082         subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1083         default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1084         related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1085         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1086         if not related_subtypes or not any(relation in updated_fields for relation in relation_fields):
1087             return True
1088
1089         for record in self.browse(cr, uid, ids, context=context):
1090             new_followers = dict()
1091             parent_res_id = False
1092             parent_model = False
1093             for subtype in related_subtypes:
1094                 if not subtype.relation_field or not subtype.parent_id:
1095                     continue
1096                 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1097                     continue
1098                 parent_res_id = getattr(record, subtype.relation_field).id
1099                 parent_model = subtype.res_model
1100                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1101                     ('res_model', '=', parent_model),
1102                     ('res_id', '=', parent_res_id),
1103                     ('subtype_ids', 'in', [subtype.id])
1104                     ], context=context)
1105                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1106                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1107
1108             if not parent_res_id or not parent_model:
1109                 continue
1110
1111             for subtype in default_subtypes:
1112                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1113                     ('res_model', '=', parent_model),
1114                     ('res_id', '=', parent_res_id),
1115                     ('subtype_ids', 'in', [subtype.id])
1116                     ], context=context)
1117                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1118                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1119
1120             for pid, subtypes in new_followers.items():
1121                 self.message_subscribe(cr, uid, [record.id], [pid], list(subtypes), context=context)
1122         return True
1123
1124     #------------------------------------------------------
1125     # Thread state
1126     #------------------------------------------------------
1127
1128     def message_mark_as_unread(self, cr, uid, ids, context=None):
1129         """ Set as unread. """
1130         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1131         cr.execute('''
1132             UPDATE mail_notification SET
1133                 read=false
1134             WHERE
1135                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1136                 partner_id = %s
1137         ''', (ids, self._name, partner_id))
1138         return True
1139
1140     def message_mark_as_read(self, cr, uid, ids, context=None):
1141         """ Set as read. """
1142         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1143         cr.execute('''
1144             UPDATE mail_notification SET
1145                 read=true
1146             WHERE
1147                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1148                 partner_id = %s
1149         ''', (ids, self._name, partner_id))
1150         return True
1151
1152 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: