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