[FIX] mail: missing .id during forward port 3c0292645fcb9604e3c4f08a37b7e8702d464065
[odoo/odoo.git] / addons / mail / mail_message.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2010-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 logging
23 import re
24
25 from openerp import tools
26
27 from email.header import decode_header
28 from openerp import SUPERUSER_ID
29 from openerp.osv import osv, orm, fields
30 from openerp.tools import html_email_clean
31 from openerp.tools.translate import _
32 from HTMLParser import HTMLParser
33
34 _logger = logging.getLogger(__name__)
35
36 try:
37     from mako.template import Template as MakoTemplate
38 except ImportError:
39     _logger.warning("payment_acquirer: mako templates not available, payment acquirer will not work!")
40
41
42 """ Some tools for parsing / creating email fields """
43 def decode(text):
44     """Returns unicode() string conversion of the the given encoded smtp header text"""
45     if text:
46         text = decode_header(text.replace('\r', ''))
47         return ''.join([tools.ustr(x[0], x[1]) for x in text])
48
49 class MLStripper(HTMLParser):
50     def __init__(self):
51         self.reset()
52         self.fed = []
53     def handle_data(self, d):
54         self.fed.append(d)
55     def get_data(self):
56         return ''.join(self.fed)
57
58 def strip_tags(html):
59     s = MLStripper()
60     s.feed(html)
61     return s.get_data()
62
63 class mail_message(osv.Model):
64     """ Messages model: system notification (replacing res.log notifications),
65         comments (OpenChatter discussion) and incoming emails. """
66     _name = 'mail.message'
67     _description = 'Message'
68     _inherit = ['ir.needaction_mixin']
69     _order = 'id desc'
70     _rec_name = 'record_name'
71
72     _message_read_limit = 30
73     _message_read_fields = ['id', 'parent_id', 'model', 'res_id', 'body', 'subject', 'date', 'to_read', 'email_from',
74         'type', 'vote_user_ids', 'attachment_ids', 'author_id', 'partner_ids', 'record_name']
75     _message_record_name_length = 18
76     _message_read_more_limit = 1024
77
78     def default_get(self, cr, uid, fields, context=None):
79         # protection for `default_type` values leaking from menu action context (e.g. for invoices)
80         if context and context.get('default_type') and context.get('default_type') not in self._columns['type'].selection:
81             context = dict(context, default_type=None)
82         return super(mail_message, self).default_get(cr, uid, fields, context=context)
83
84     def _shorten_name(self, name):
85         if len(name) <= (self._message_record_name_length + 3):
86             return name
87         return name[:self._message_record_name_length] + '...'
88
89     def _get_record_name(self, cr, uid, ids, name, arg, context=None):
90         """ Return the related document name, using name_get. It is done using
91             SUPERUSER_ID, to be sure to have the record name correctly stored. """
92         # TDE note: regroup by model/ids, to have less queries to perform
93         result = dict.fromkeys(ids, False)
94         for message in self.read(cr, uid, ids, ['model', 'res_id'], context=context):
95             if not message.get('model') or not message.get('res_id') or message['model'] not in self.pool:
96                 continue
97             result[message['id']] = self.pool[message['model']].name_get(cr, SUPERUSER_ID, [message['res_id']], context=context)[0][1]
98         return result
99
100     def _get_to_read(self, cr, uid, ids, name, arg, context=None):
101         """ Compute if the message is unread by the current user. """
102         res = dict((id, False) for id in ids)
103         partner_id = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
104         notif_obj = self.pool.get('mail.notification')
105         notif_ids = notif_obj.search(cr, uid, [
106             ('partner_id', 'in', [partner_id]),
107             ('message_id', 'in', ids),
108             ('read', '=', False),
109         ], context=context)
110         for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
111             res[notif.message_id.id] = True
112         return res
113
114     def _search_to_read(self, cr, uid, obj, name, domain, context=None):
115         """ Search for messages to read by the current user. Condition is
116             inversed because we search unread message on a read column. """
117         return ['&', ('notification_ids.partner_id.user_ids', 'in', [uid]), ('notification_ids.read', '=', not domain[0][2])]
118
119     def _get_starred(self, cr, uid, ids, name, arg, context=None):
120         """ Compute if the message is unread by the current user. """
121         res = dict((id, False) for id in ids)
122         partner_id = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
123         notif_obj = self.pool.get('mail.notification')
124         notif_ids = notif_obj.search(cr, uid, [
125             ('partner_id', 'in', [partner_id]),
126             ('message_id', 'in', ids),
127             ('starred', '=', True),
128         ], context=context)
129         for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
130             res[notif.message_id.id] = True
131         return res
132
133     def _search_starred(self, cr, uid, obj, name, domain, context=None):
134         """ Search for messages to read by the current user. Condition is
135             inversed because we search unread message on a read column. """
136         return ['&', ('notification_ids.partner_id.user_ids', 'in', [uid]), ('notification_ids.starred', '=', domain[0][2])]
137
138     def name_get(self, cr, uid, ids, context=None):
139         # name_get may receive int id instead of an id list
140         if isinstance(ids, (int, long)):
141             ids = [ids]
142         res = []
143         for message in self.browse(cr, uid, ids, context=context):
144             name = '%s: %s' % (message.subject or '', strip_tags(message.body or '') or '')
145             res.append((message.id, self._shorten_name(name.lstrip(' :'))))
146         return res
147
148     _columns = {
149         'type': fields.selection([
150                         ('email', 'Email'),
151                         ('comment', 'Comment'),
152                         ('notification', 'System notification'),
153                         ], 'Type',
154             help="Message type: email for email message, notification for system "\
155                  "message, comment for other messages such as user replies"),
156         'email_from': fields.char('From',
157             help="Email address of the sender. This field is set when no matching partner is found for incoming emails."),
158         'reply_to': fields.char('Reply-To',
159             help='Reply email address. Setting the reply_to bypasses the automatic thread creation.'),
160         'author_id': fields.many2one('res.partner', 'Author', select=1,
161             ondelete='set null',
162             help="Author of the message. If not set, email_from may hold an email address that did not match any partner."),
163         'author_avatar': fields.related('author_id', 'image_small', type="binary", string="Author's Avatar"),
164         'partner_ids': fields.many2many('res.partner', string='Recipients'),
165         'notified_partner_ids': fields.many2many('res.partner', 'mail_notification',
166             'message_id', 'partner_id', 'Notified partners',
167             help='Partners that have a notification pushing this message in their mailboxes'),
168         'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel',
169             'message_id', 'attachment_id', 'Attachments'),
170         'parent_id': fields.many2one('mail.message', 'Parent Message', select=True,
171             ondelete='set null', help="Initial thread message."),
172         'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
173         'model': fields.char('Related Document Model', size=128, select=1),
174         'res_id': fields.integer('Related Document ID', select=1),
175         'record_name': fields.function(_get_record_name, type='char',
176             store=True, string='Message Record Name',
177             help="Name get of the related document."),
178         'notification_ids': fields.one2many('mail.notification', 'message_id',
179             string='Notifications', auto_join=True,
180             help='Technical field holding the message notifications. Use notified_partner_ids to access notified partners.'),
181         'subject': fields.char('Subject'),
182         'date': fields.datetime('Date'),
183         'message_id': fields.char('Message-Id', help='Message unique identifier', select=1, readonly=1),
184         'body': fields.html('Contents', help='Automatically sanitized HTML contents'),
185         'to_read': fields.function(_get_to_read, fnct_search=_search_to_read,
186             type='boolean', string='To read',
187             help='Current user has an unread notification linked to this message'),
188         'starred': fields.function(_get_starred, fnct_search=_search_starred,
189             type='boolean', string='Starred',
190             help='Current user has a starred notification linked to this message'),
191         'subtype_id': fields.many2one('mail.message.subtype', 'Subtype',
192             ondelete='set null', select=1,),
193         'vote_user_ids': fields.many2many('res.users', 'mail_vote',
194             'message_id', 'user_id', string='Votes',
195             help='Users that voted for this message'),
196         'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
197     }
198
199     def _needaction_domain_get(self, cr, uid, context=None):
200         return [('to_read', '=', True)]
201
202     def _get_default_from(self, cr, uid, context=None):
203         this = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
204         if this.alias_name and this.alias_domain:
205             return '%s <%s@%s>' % (this.name, this.alias_name, this.alias_domain)
206         elif this.email:
207             return '%s <%s>' % (this.name, this.email)
208         raise osv.except_osv(_('Invalid Action!'), _("Unable to send email, please configure the sender's email address or alias."))
209
210     def _get_default_author(self, cr, uid, context=None):
211         return self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
212
213     _defaults = {
214         'type': 'email',
215         'date': fields.datetime.now,
216         'author_id': lambda self, cr, uid, ctx=None: self._get_default_author(cr, uid, ctx),
217         'body': '',
218         'email_from': lambda self, cr, uid, ctx=None: self._get_default_from(cr, uid, ctx),
219     }
220
221     #------------------------------------------------------
222     # Vote/Like
223     #------------------------------------------------------
224
225     def vote_toggle(self, cr, uid, ids, context=None):
226         ''' Toggles vote. Performed using read to avoid access rights issues.
227             Done as SUPERUSER_ID because uid may vote for a message he cannot modify. '''
228         for message in self.read(cr, uid, ids, ['vote_user_ids'], context=context):
229             new_has_voted = not (uid in message.get('vote_user_ids'))
230             if new_has_voted:
231                 self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(4, uid)]}, context=context)
232             else:
233                 self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(3, uid)]}, context=context)
234         return new_has_voted or False
235
236     #------------------------------------------------------
237     # download an attachment
238     #------------------------------------------------------
239
240     def download_attachment(self, cr, uid, id_message, attachment_id, context=None):
241         """ Return the content of linked attachments. """
242         message = self.browse(cr, uid, id_message, context=context)
243         if attachment_id in [attachment.id for attachment in message.attachment_ids]:
244             attachment = self.pool.get('ir.attachment').browse(cr, SUPERUSER_ID, attachment_id, context=context)
245             if attachment.datas and attachment.datas_fname:
246                 return {
247                     'base64': attachment.datas,
248                     'filename': attachment.datas_fname,
249                 }
250         return False
251
252     #------------------------------------------------------
253     # Notification API
254     #------------------------------------------------------
255
256     def set_message_read(self, cr, uid, msg_ids, read, create_missing=True, context=None):
257         """ Set messages as (un)read. Technically, the notifications related
258             to uid are set to (un)read. If for some msg_ids there are missing
259             notifications (i.e. due to load more or thread parent fetching),
260             they are created.
261
262             :param bool read: set notification as (un)read
263             :param bool create_missing: create notifications for missing entries
264                 (i.e. when acting on displayed messages not notified)
265
266             :return number of message mark as read
267         """
268         notification_obj = self.pool.get('mail.notification')
269         user_pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
270         domain = [('partner_id', '=', user_pid), ('message_id', 'in', msg_ids)]
271         if not create_missing:
272             domain += [('read', '=', not read)]
273         notif_ids = notification_obj.search(cr, uid, domain, context=context)
274
275         # all message have notifications: already set them as (un)read
276         if len(notif_ids) == len(msg_ids) or not create_missing:
277             notification_obj.write(cr, uid, notif_ids, {'read': read}, context=context)
278             return len(notif_ids)
279
280         # some messages do not have notifications: find which one, create notification, update read status
281         notified_msg_ids = [notification.message_id.id for notification in notification_obj.browse(cr, uid, notif_ids, context=context)]
282         to_create_msg_ids = list(set(msg_ids) - set(notified_msg_ids))
283         for msg_id in to_create_msg_ids:
284             notification_obj.create(cr, uid, {'partner_id': user_pid, 'read': read, 'message_id': msg_id}, context=context)
285         notification_obj.write(cr, uid, notif_ids, {'read': read}, context=context)
286         return len(notif_ids)
287
288     def set_message_starred(self, cr, uid, msg_ids, starred, create_missing=True, context=None):
289         """ Set messages as (un)starred. Technically, the notifications related
290             to uid are set to (un)starred.
291
292             :param bool starred: set notification as (un)starred
293             :param bool create_missing: create notifications for missing entries
294                 (i.e. when acting on displayed messages not notified)
295         """
296         notification_obj = self.pool.get('mail.notification')
297         user_pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
298         domain = [('partner_id', '=', user_pid), ('message_id', 'in', msg_ids)]
299         if not create_missing:
300             domain += [('starred', '=', not starred)]
301         values = {
302             'starred': starred
303         }
304         if starred:
305             values['read'] = False
306
307         notif_ids = notification_obj.search(cr, uid, domain, context=context)
308
309         # all message have notifications: already set them as (un)starred
310         if len(notif_ids) == len(msg_ids) or not create_missing:
311             notification_obj.write(cr, uid, notif_ids, values, context=context)
312             return starred
313
314         # some messages do not have notifications: find which one, create notification, update starred status
315         notified_msg_ids = [notification.message_id.id for notification in notification_obj.browse(cr, uid, notif_ids, context=context)]
316         to_create_msg_ids = list(set(msg_ids) - set(notified_msg_ids))
317         for msg_id in to_create_msg_ids:
318             notification_obj.create(cr, uid, dict(values, partner_id=user_pid, message_id=msg_id), context=context)
319         notification_obj.write(cr, uid, notif_ids, values, context=context)
320         return starred
321
322     #------------------------------------------------------
323     # Message loading for web interface
324     #------------------------------------------------------
325
326     def _message_read_dict_postprocess(self, cr, uid, messages, message_tree, context=None):
327         """ Post-processing on values given by message_read. This method will
328             handle partners in batch to avoid doing numerous queries.
329
330             :param list messages: list of message, as get_dict result
331             :param dict message_tree: {[msg.id]: msg browse record}
332         """
333         res_partner_obj = self.pool.get('res.partner')
334         ir_attachment_obj = self.pool.get('ir.attachment')
335         pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
336
337         # 1. Aggregate partners (author_id and partner_ids) and attachments
338         partner_ids = set()
339         attachment_ids = set()
340         for key, message in message_tree.iteritems():
341             if message.author_id:
342                 partner_ids |= set([message.author_id.id])
343             if message.subtype_id and message.notified_partner_ids:  # take notified people of message with a subtype
344                 partner_ids |= set([partner.id for partner in message.notified_partner_ids])
345             elif not message.subtype_id and message.partner_ids:  # take specified people of message without a subtype (log)
346                 partner_ids |= set([partner.id for partner in message.partner_ids])
347             if message.attachment_ids:
348                 attachment_ids |= set([attachment.id for attachment in message.attachment_ids])
349         # Read partners as SUPERUSER -> display the names like classic m2o even if no access
350         partners = res_partner_obj.name_get(cr, SUPERUSER_ID, list(partner_ids), context=context)
351         partner_tree = dict((partner[0], partner) for partner in partners)
352
353         # 2. Attachments as SUPERUSER, because could receive msg and attachments for doc uid cannot see
354         attachments = ir_attachment_obj.read(cr, SUPERUSER_ID, list(attachment_ids), ['id', 'datas_fname', 'name', 'file_type_icon'], context=context)
355         attachments_tree = dict((attachment['id'], {
356             'id': attachment['id'],
357             'filename': attachment['datas_fname'],
358             'name': attachment['name'],
359             'file_type_icon': attachment['file_type_icon'],
360         }) for attachment in attachments)
361
362         # 3. Update message dictionaries
363         for message_dict in messages:
364             message_id = message_dict.get('id')
365             message = message_tree[message_id]
366             if message.author_id:
367                 author = partner_tree[message.author_id.id]
368             else:
369                 author = (0, message.email_from)
370             partner_ids = []
371             if message.subtype_id:
372                 partner_ids = [partner_tree[partner.id] for partner in message.notified_partner_ids
373                                 if partner.id in partner_tree]
374             else:
375                 partner_ids = [partner_tree[partner.id] for partner in message.partner_ids
376                                 if partner.id in partner_tree]
377             attachment_ids = []
378             for attachment in message.attachment_ids:
379                 if attachment.id in attachments_tree:
380                     attachment_ids.append(attachments_tree[attachment.id])
381             message_dict.update({
382                 'is_author': pid == author[0],
383                 'author_id': author,
384                 'partner_ids': partner_ids,
385                 'attachment_ids': attachment_ids,
386                 'user_pid': pid
387                 })
388         return True
389
390     def _message_read_dict(self, cr, uid, message, parent_id=False, context=None):
391         """ Return a dict representation of the message. This representation is
392             used in the JS client code, to display the messages. Partners and
393             attachments related stuff will be done in post-processing in batch.
394
395             :param dict message: mail.message browse record
396         """
397         # private message: no model, no res_id
398         is_private = False
399         if not message.model or not message.res_id:
400             is_private = True
401         # votes and favorites: res.users ids, no prefetching should be done
402         vote_nb = len(message.vote_user_ids)
403         has_voted = uid in [user.id for user in message.vote_user_ids]
404
405         try:
406             if parent_id:
407                 max_length = 300
408             else:
409                 max_length = 100
410             body_short = html_email_clean(message.body, remove=False, shorten=True, max_length=max_length)
411
412         except Exception:
413             body_short = '<p><b>Encoding Error : </b><br/>Unable to convert this message (id: %s).</p>' % message.id
414             _logger.exception(Exception)
415
416         return {'id': message.id,
417                 'type': message.type,
418                 'subtype': message.subtype_id.name if message.subtype_id else False,
419                 'body': message.body,
420                 'body_short': body_short,
421                 'model': message.model,
422                 'res_id': message.res_id,
423                 'record_name': message.record_name,
424                 'subject': message.subject,
425                 'date': message.date,
426                 'to_read': message.to_read,
427                 'parent_id': parent_id,
428                 'is_private': is_private,
429                 'author_id': False,
430                 'author_avatar': message.author_avatar,
431                 'is_author': False,
432                 'partner_ids': [],
433                 'vote_nb': vote_nb,
434                 'has_voted': has_voted,
435                 'is_favorite': message.starred,
436                 'attachment_ids': [],
437             }
438
439     def _message_read_add_expandables(self, cr, uid, messages, message_tree, parent_tree,
440             message_unload_ids=[], thread_level=0, domain=[], parent_id=False, context=None):
441         """ Create expandables for message_read, to load new messages.
442             1. get the expandable for new threads
443                 if display is flat (thread_level == 0):
444                     fetch message_ids < min(already displayed ids), because we
445                     want a flat display, ordered by id
446                 else:
447                     fetch message_ids that are not childs of already displayed
448                     messages
449             2. get the expandables for new messages inside threads if display
450                is not flat
451                 for each thread header, search for its childs
452                     for each hole in the child list based on message displayed,
453                     create an expandable
454
455             :param list messages: list of message structure for the Chatter
456                 widget to which expandables are added
457             :param dict message_tree: dict [id]: browse record of this message
458             :param dict parent_tree: dict [parent_id]: [child_ids]
459             :param list message_unload_ids: list of message_ids we do not want
460                 to load
461             :return bool: True
462         """
463         def _get_expandable(domain, message_nb, parent_id, max_limit):
464             return {
465                 'domain': domain,
466                 'nb_messages': message_nb,
467                 'type': 'expandable',
468                 'parent_id': parent_id,
469                 'max_limit':  max_limit,
470             }
471
472         if not messages:
473             return True
474         message_ids = sorted(message_tree.keys())
475
476         # 1. get the expandable for new threads
477         if thread_level == 0:
478             exp_domain = domain + [('id', '<', min(message_unload_ids + message_ids))]
479         else:
480             exp_domain = domain + ['!', ('id', 'child_of', message_unload_ids + parent_tree.keys())]
481         ids = self.search(cr, uid, exp_domain, context=context, limit=1)
482         if ids:
483             # inside a thread: prepend
484             if parent_id:
485                 messages.insert(0, _get_expandable(exp_domain, -1, parent_id, True))
486             # new threads: append
487             else:
488                 messages.append(_get_expandable(exp_domain, -1, parent_id, True))
489
490         # 2. get the expandables for new messages inside threads if display is not flat
491         if thread_level == 0:
492             return True
493         for message_id in message_ids:
494             message = message_tree[message_id]
495
496             # generate only for thread header messages (TDE note: parent_id may be False is uid cannot see parent_id, seems ok)
497             if message.parent_id:
498                 continue
499
500             # check there are message for expandable
501             child_ids = set([child.id for child in message.child_ids]) - set(message_unload_ids)
502             child_ids = sorted(list(child_ids), reverse=True)
503             if not child_ids:
504                 continue
505
506             # make groups of unread messages
507             id_min, id_max, nb = max(child_ids), 0, 0
508             for child_id in child_ids:
509                 if not child_id in message_ids:
510                     nb += 1
511                     if id_min > child_id:
512                         id_min = child_id
513                     if id_max < child_id:
514                         id_max = child_id
515                 elif nb > 0:
516                     exp_domain = [('id', '>=', id_min), ('id', '<=', id_max), ('id', 'child_of', message_id)]
517                     idx = [msg.get('id') for msg in messages].index(child_id) + 1
518                     # messages.append(_get_expandable(exp_domain, nb, message_id, False))
519                     messages.insert(idx, _get_expandable(exp_domain, nb, message_id, False))
520                     id_min, id_max, nb = max(child_ids), 0, 0
521                 else:
522                     id_min, id_max, nb = max(child_ids), 0, 0
523             if nb > 0:
524                 exp_domain = [('id', '>=', id_min), ('id', '<=', id_max), ('id', 'child_of', message_id)]
525                 idx = [msg.get('id') for msg in messages].index(message_id) + 1
526                 # messages.append(_get_expandable(exp_domain, nb, message_id, id_min))
527                 messages.insert(idx, _get_expandable(exp_domain, nb, message_id, False))
528
529         return True
530
531     def message_read(self, cr, uid, ids=None, domain=None, message_unload_ids=None,
532                         thread_level=0, context=None, parent_id=False, limit=None):
533         """ Read messages from mail.message, and get back a list of structured
534             messages to be displayed as discussion threads. If IDs is set,
535             fetch these records. Otherwise use the domain to fetch messages.
536             After having fetch messages, their ancestors will be added to obtain
537             well formed threads, if uid has access to them.
538
539             After reading the messages, expandable messages are added in the
540             message list (see ``_message_read_add_expandables``). It consists
541             in messages holding the 'read more' data: number of messages to
542             read, domain to apply.
543
544             :param list ids: optional IDs to fetch
545             :param list domain: optional domain for searching ids if ids not set
546             :param list message_unload_ids: optional ids we do not want to fetch,
547                 because i.e. they are already displayed somewhere
548             :param int parent_id: context of parent_id
549                 - if parent_id reached when adding ancestors, stop going further
550                   in the ancestor search
551                 - if set in flat mode, ancestor_id is set to parent_id
552             :param int limit: number of messages to fetch, before adding the
553                 ancestors and expandables
554             :return list: list of message structure for the Chatter widget
555         """
556         assert thread_level in [0, 1], 'message_read() thread_level should be 0 (flat) or 1 (1 level of thread); given %s.' % thread_level
557         domain = domain if domain is not None else []
558         message_unload_ids = message_unload_ids if message_unload_ids is not None else []
559         if message_unload_ids:
560             domain += [('id', 'not in', message_unload_ids)]
561         limit = limit or self._message_read_limit
562         message_tree = {}
563         message_list = []
564         parent_tree = {}
565
566         # no specific IDS given: fetch messages according to the domain, add their parents if uid has access to
567         if ids is None:
568             ids = self.search(cr, uid, domain, context=context, limit=limit)
569
570         # fetch parent if threaded, sort messages
571         for message in self.browse(cr, uid, ids, context=context):
572             message_id = message.id
573             if message_id in message_tree:
574                 continue
575             message_tree[message_id] = message
576
577             # find parent_id
578             if thread_level == 0:
579                 tree_parent_id = parent_id
580             else:
581                 tree_parent_id = message_id
582                 parent = message
583                 while parent.parent_id and parent.parent_id.id != parent_id:
584                     parent = parent.parent_id
585                     tree_parent_id = parent.id
586                 if not parent.id in message_tree:
587                     message_tree[parent.id] = parent
588             # newest messages first
589             parent_tree.setdefault(tree_parent_id, [])
590             if tree_parent_id != message_id:
591                 parent_tree[tree_parent_id].append(self._message_read_dict(cr, uid, message_tree[message_id], parent_id=tree_parent_id, context=context))
592
593         if thread_level:
594             for key, message_id_list in parent_tree.iteritems():
595                 message_id_list.sort(key=lambda item: item['id'])
596                 message_id_list.insert(0, self._message_read_dict(cr, uid, message_tree[key], context=context))
597
598         # create final ordered message_list based on parent_tree
599         parent_list = parent_tree.items()
600         parent_list = sorted(parent_list, key=lambda item: max([msg.get('id') for msg in item[1]]) if item[1] else item[0], reverse=True)
601         message_list = [message for (key, msg_list) in parent_list for message in msg_list]
602
603         # get the child expandable messages for the tree
604         self._message_read_dict_postprocess(cr, uid, message_list, message_tree, context=context)
605         self._message_read_add_expandables(cr, uid, message_list, message_tree, parent_tree,
606             thread_level=thread_level, message_unload_ids=message_unload_ids, domain=domain, parent_id=parent_id, context=context)
607         return message_list
608
609     #------------------------------------------------------
610     # mail_message internals
611     #------------------------------------------------------
612
613     def init(self, cr):
614         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
615         if not cr.fetchone():
616             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
617
618     def _find_allowed_model_wise(self, cr, uid, doc_model, doc_dict, context=None):
619         doc_ids = doc_dict.keys()
620         allowed_doc_ids = self.pool[doc_model].search(cr, uid, [('id', 'in', doc_ids)], context=context)
621         return set([message_id for allowed_doc_id in allowed_doc_ids for message_id in doc_dict[allowed_doc_id]])
622
623     def _find_allowed_doc_ids(self, cr, uid, model_ids, context=None):
624         model_access_obj = self.pool.get('ir.model.access')
625         allowed_ids = set()
626         for doc_model, doc_dict in model_ids.iteritems():
627             if not model_access_obj.check(cr, uid, doc_model, 'read', False):
628                 continue
629             allowed_ids |= self._find_allowed_model_wise(cr, uid, doc_model, doc_dict, context=context)
630         return allowed_ids
631
632     def _search(self, cr, uid, args, offset=0, limit=None, order=None,
633         context=None, count=False, access_rights_uid=None):
634         """ Override that adds specific access rights of mail.message, to remove
635             ids uid could not see according to our custom rules. Please refer
636             to check_access_rule for more details about those rules.
637
638             After having received ids of a classic search, keep only:
639             - if author_id == pid, uid is the author, OR
640             - a notification (id, pid) exists, uid has been notified, OR
641             - uid have read access to the related document is model, res_id
642             - otherwise: remove the id
643         """
644         # Rules do not apply to administrator
645         if uid == SUPERUSER_ID:
646             return super(mail_message, self)._search(cr, uid, args, offset=offset, limit=limit, order=order,
647                 context=context, count=count, access_rights_uid=access_rights_uid)
648         # Perform a super with count as False, to have the ids, not a counter
649         ids = super(mail_message, self)._search(cr, uid, args, offset=offset, limit=limit, order=order,
650             context=context, count=False, access_rights_uid=access_rights_uid)
651         if not ids and count:
652             return 0
653         elif not ids:
654             return ids
655
656         pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).partner_id.id
657         author_ids, partner_ids, allowed_ids = set([]), set([]), set([])
658         model_ids = {}
659
660         messages = super(mail_message, self).read(cr, uid, ids, ['author_id', 'model', 'res_id', 'notified_partner_ids'], context=context)
661         for message in messages:
662             if message.get('author_id') and message.get('author_id')[0] == pid:
663                 author_ids.add(message.get('id'))
664             elif pid in message.get('notified_partner_ids'):
665                 partner_ids.add(message.get('id'))
666             elif message.get('model') and message.get('res_id'):
667                 model_ids.setdefault(message.get('model'), {}).setdefault(message.get('res_id'), set()).add(message.get('id'))
668
669         allowed_ids = self._find_allowed_doc_ids(cr, uid, model_ids, context=context)
670         final_ids = author_ids | partner_ids | allowed_ids
671
672         if count:
673             return len(final_ids)
674         else:
675             # re-construct a list based on ids, because set did not keep the original order
676             id_list = [id for id in ids if id in final_ids]
677             return id_list
678
679     def check_access_rule(self, cr, uid, ids, operation, context=None):
680         """ Access rules of mail.message:
681             - read: if
682                 - author_id == pid, uid is the author, OR
683                 - mail_notification (id, pid) exists, uid has been notified, OR
684                 - uid have read access to the related document if model, res_id
685                 - otherwise: raise
686             - create: if
687                 - no model, no res_id, I create a private message OR
688                 - pid in message_follower_ids if model, res_id OR
689                 - mail_notification (parent_id.id, pid) exists, uid has been notified of the parent, OR
690                 - uid have write or create access on the related document if model, res_id, OR
691                 - otherwise: raise
692             - write: if
693                 - author_id == pid, uid is the author, OR
694                 - uid has write or create access on the related document if model, res_id
695                 - otherwise: raise
696             - unlink: if
697                 - uid has write or create access on the related document if model, res_id
698                 - otherwise: raise
699         """
700         def _generate_model_record_ids(msg_val, msg_ids=[]):
701             """ :param model_record_ids: {'model': {'res_id': (msg_id, msg_id)}, ... }
702                 :param message_values: {'msg_id': {'model': .., 'res_id': .., 'author_id': ..}}
703             """
704             model_record_ids = {}
705             for id in msg_ids:
706                 vals = msg_val.get(id, {})
707                 if vals.get('model') and vals.get('res_id'):
708                     model_record_ids.setdefault(vals['model'], set()).add(vals['res_id'])
709             return model_record_ids
710
711         if uid == SUPERUSER_ID:
712             return
713         if isinstance(ids, (int, long)):
714             ids = [ids]
715         not_obj = self.pool.get('mail.notification')
716         fol_obj = self.pool.get('mail.followers')
717         partner_id = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=None).partner_id.id
718
719         # Read mail_message.ids to have their values
720         message_values = dict.fromkeys(ids, {})
721         cr.execute('SELECT DISTINCT id, model, res_id, author_id, parent_id FROM "%s" WHERE id = ANY (%%s)' % self._table, (ids,))
722         for id, rmod, rid, author_id, parent_id in cr.fetchall():
723             message_values[id] = {'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id}
724
725         # Author condition (READ, WRITE, CREATE (private)) -> could become an ir.rule ?
726         author_ids = []
727         if operation == 'read' or operation == 'write':
728             author_ids = [mid for mid, message in message_values.iteritems()
729                 if message.get('author_id') and message.get('author_id') == partner_id]
730         elif operation == 'create':
731             author_ids = [mid for mid, message in message_values.iteritems()
732                 if not message.get('model') and not message.get('res_id')]
733
734         # Parent condition, for create (check for received notifications for the created message parent)
735         notified_ids = []
736         if operation == 'create':
737             parent_ids = [message.get('parent_id') for mid, message in message_values.iteritems()
738                 if message.get('parent_id')]
739             not_ids = not_obj.search(cr, SUPERUSER_ID, [('message_id.id', 'in', parent_ids), ('partner_id', '=', partner_id)], context=context)
740             not_parent_ids = [notif.message_id.id for notif in not_obj.browse(cr, SUPERUSER_ID, not_ids, context=context)]
741             notified_ids += [mid for mid, message in message_values.iteritems()
742                 if message.get('parent_id') in not_parent_ids]
743
744         # Notification condition, for read (check for received notifications and create (in message_follower_ids)) -> could become an ir.rule, but not till we do not have a many2one variable field
745         other_ids = set(ids).difference(set(author_ids), set(notified_ids))
746         model_record_ids = _generate_model_record_ids(message_values, other_ids)
747         if operation == 'read':
748             not_ids = not_obj.search(cr, SUPERUSER_ID, [
749                 ('partner_id', '=', partner_id),
750                 ('message_id', 'in', ids),
751             ], context=context)
752             notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, SUPERUSER_ID, not_ids, context=context)]
753         elif operation == 'create':
754             for doc_model, doc_ids in model_record_ids.items():
755                 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [
756                     ('res_model', '=', doc_model),
757                     ('res_id', 'in', list(doc_ids)),
758                     ('partner_id', '=', partner_id),
759                     ], context=context)
760                 fol_mids = [follower.res_id for follower in fol_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context)]
761                 notified_ids += [mid for mid, message in message_values.iteritems()
762                     if message.get('model') == doc_model and message.get('res_id') in fol_mids]
763
764         # CRUD: Access rights related to the document
765         other_ids = other_ids.difference(set(notified_ids))
766         model_record_ids = _generate_model_record_ids(message_values, other_ids)
767         document_related_ids = []
768         for model, doc_ids in model_record_ids.items():
769             model_obj = self.pool[model]
770             mids = model_obj.exists(cr, uid, list(doc_ids))
771             if hasattr(model_obj, 'check_mail_message_access'):
772                 model_obj.check_mail_message_access(cr, uid, mids, operation, context=context)
773             else:
774                 self.pool['mail.thread'].check_mail_message_access(cr, uid, mids, operation, model_obj=model_obj, context=context)
775             document_related_ids += [mid for mid, message in message_values.iteritems()
776                 if message.get('model') == model and message.get('res_id') in mids]
777
778         # Calculate remaining ids: if not void, raise an error
779         other_ids = other_ids.difference(set(document_related_ids))
780         if not other_ids:
781             return
782         raise orm.except_orm(_('Access Denied'),
783                             _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % \
784                             (self._description, operation))
785
786     def _get_reply_to(self, cr, uid, values, context=None):
787         """ Return a specific reply_to: alias of the document through message_get_reply_to
788             or take the email_from
789         """
790         email_reply_to = None
791
792         ir_config_parameter = self.pool.get("ir.config_parameter")
793         catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
794
795         # model, res_id, email_from: comes from values OR related message
796         model, res_id, email_from = values.get('model'), values.get('res_id'), values.get('email_from')
797
798         # if model and res_id: try to use ``message_get_reply_to`` that returns the document alias
799         if not email_reply_to and model and res_id and catchall_domain and hasattr(self.pool[model], 'message_get_reply_to'):
800             email_reply_to = self.pool[model].message_get_reply_to(cr, uid, [res_id], context=context)[0]
801         # no alias reply_to -> catchall alias
802         if not email_reply_to and catchall_domain:
803             catchall_alias = ir_config_parameter.get_param(cr, uid, "mail.catchall.alias", context=context)
804             if catchall_alias:
805                 email_reply_to = '%s@%s' % (catchall_alias, catchall_domain)
806         # still no reply_to -> reply_to will be the email_from
807         if not email_reply_to and email_from:
808             email_reply_to = email_from
809
810         # format 'Document name <email_address>'
811         if email_reply_to and model and res_id:
812             emails = tools.email_split(email_reply_to)
813             if emails:
814                 email_reply_to = emails[0]
815             document_name = self.pool[model].name_get(cr, SUPERUSER_ID, [res_id], context=context)[0]
816             if document_name:
817                 # sanitize document name
818                 sanitized_doc_name = re.sub(r'[^\w+.]+', '-', document_name[1])
819                 # generate reply to
820                 email_reply_to = _('"Followers of %s" <%s>') % (sanitized_doc_name, email_reply_to)
821
822         return email_reply_to
823
824     def _get_message_id(self, cr, uid, values, context=None):
825         if values.get('reply_to'):
826             message_id = tools.generate_tracking_message_id('reply_to')
827         elif values.get('res_id') and values.get('model'):
828             message_id = tools.generate_tracking_message_id('%(res_id)s-%(model)s' % values)
829         else:
830             message_id = tools.generate_tracking_message_id('private')
831         return message_id
832
833     def create(self, cr, uid, values, context=None):
834         if context is None:
835             context = {}
836         default_starred = context.pop('default_starred', False)
837
838         if 'email_from' not in values:  # needed to compute reply_to
839             values['email_from'] = self._get_default_from(cr, uid, context=context)
840         if 'message_id' not in values:
841             values['message_id'] = self._get_message_id(cr, uid, values, context=context)
842         if 'reply_to' not in values:
843             values['reply_to'] = self._get_reply_to(cr, uid, values, context=context)
844
845         newid = super(mail_message, self).create(cr, uid, values, context)
846         self._notify(cr, uid, newid, context=context,
847                      force_send=context.get('mail_notify_force_send', True),
848                      user_signature=context.get('mail_notify_user_signature', True))
849         # TDE FIXME: handle default_starred. Why not setting an inv on starred ?
850         # Because starred will call set_message_starred, that looks for notifications.
851         # When creating a new mail_message, it will create a notification to a message
852         # that does not exist, leading to an error (key not existing). Also this
853         # this means unread notifications will be created, yet we can not assure
854         # this is what we want.
855         if default_starred:
856             self.set_message_starred(cr, uid, [newid], True, context=context)
857         return newid
858
859     def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
860         """ Override to explicitely call check_access_rule, that is not called
861             by the ORM. It instead directly fetches ir.rules and apply them. """
862         self.check_access_rule(cr, uid, ids, 'read', context=context)
863         res = super(mail_message, self).read(cr, uid, ids, fields=fields, context=context, load=load)
864         return res
865
866     def unlink(self, cr, uid, ids, context=None):
867         # cascade-delete attachments that are directly attached to the message (should only happen
868         # for mail.messages that act as parent for a standalone mail.mail record).
869         self.check_access_rule(cr, uid, ids, 'unlink', context=context)
870         attachments_to_delete = []
871         for message in self.browse(cr, uid, ids, context=context):
872             for attach in message.attachment_ids:
873                 if attach.res_model == self._name and (attach.res_id == message.id or attach.res_id == 0):
874                     attachments_to_delete.append(attach.id)
875         if attachments_to_delete:
876             self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
877         return super(mail_message, self).unlink(cr, uid, ids, context=context)
878
879     def copy(self, cr, uid, id, default=None, context=None):
880         """ Overridden to avoid duplicating fields that are unique to each email """
881         if default is None:
882             default = {}
883         default.update(message_id=False, headers=False)
884         return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
885
886     #------------------------------------------------------
887     # Messaging API
888     #------------------------------------------------------
889
890     # TDE note: this code is not used currently, will be improved in a future merge, when quoted context
891     # will be added to email send for notifications. Currently only WIP.
892     MAIL_TEMPLATE = """<div>
893     % if message:
894         ${display_message(message)}
895     % endif
896     % for ctx_msg in context_messages:
897         ${display_message(ctx_msg)}
898     % endfor
899     % if add_expandable:
900         ${display_expandable()}
901     % endif
902     ${display_message(header_message)}
903     </div>
904
905     <%def name="display_message(message)">
906         <div>
907             Subject: ${message.subject}<br />
908             Body: ${message.body}
909         </div>
910     </%def>
911
912     <%def name="display_expandable()">
913         <div>This is an expandable.</div>
914     </%def>
915     """
916
917     def message_quote_context(self, cr, uid, id, context=None, limit=3, add_original=False):
918         """
919             1. message.parent_id = False: new thread, no quote_context
920             2. get the lasts messages in the thread before message
921             3. get the message header
922             4. add an expandable between them
923
924             :param dict quote_context: options for quoting
925             :return string: html quote
926         """
927         add_expandable = False
928
929         message = self.browse(cr, uid, id, context=context)
930         if not message.parent_id:
931             return ''
932         context_ids = self.search(cr, uid, [
933             ('parent_id', '=', message.parent_id.id),
934             ('id', '<', message.id),
935             ], limit=limit, context=context)
936
937         if len(context_ids) >= limit:
938             add_expandable = True
939             context_ids = context_ids[0:-1]
940
941         context_ids.append(message.parent_id.id)
942         context_messages = self.browse(cr, uid, context_ids, context=context)
943         header_message = context_messages.pop()
944
945         try:
946             if not add_original:
947                 message = False
948             result = MakoTemplate(self.MAIL_TEMPLATE).render_unicode(message=message,
949                                                         context_messages=context_messages,
950                                                         header_message=header_message,
951                                                         add_expandable=add_expandable,
952                                                         # context kw would clash with mako internals
953                                                         ctx=context,
954                                                         format_exceptions=True)
955             result = result.strip()
956             return result
957         except Exception:
958             _logger.exception("failed to render mako template for quoting message")
959             return ''
960         return result
961
962     def _notify(self, cr, uid, newid, context=None, force_send=False, user_signature=True):
963         """ Add the related record followers to the destination partner_ids if is not a private message.
964             Call mail_notification.notify to manage the email sending
965         """
966         notification_obj = self.pool.get('mail.notification')
967         message = self.browse(cr, uid, newid, context=context)
968         partners_to_notify = set([])
969
970         # all followers of the mail.message document have to be added as partners and notified if a subtype is defined (otherwise: log message)
971         if message.subtype_id and message.model and message.res_id:
972             fol_obj = self.pool.get("mail.followers")
973             # browse as SUPERUSER because rules could restrict the search results
974             fol_ids = fol_obj.search(
975                 cr, SUPERUSER_ID, [
976                     ('res_model', '=', message.model),
977                     ('res_id', '=', message.res_id),
978                 ], context=context)
979             partners_to_notify |= set(
980                 fo.partner_id.id for fo in fol_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context)
981                 if message.subtype_id.id in [st.id for st in fo.subtype_ids]
982             )
983         # remove me from notified partners, unless the message is written on my own wall
984         if message.subtype_id and message.author_id and message.model == "res.partner" and message.res_id == message.author_id.id:
985             partners_to_notify |= set([message.author_id.id])
986         elif message.author_id:
987             partners_to_notify -= set([message.author_id.id])
988
989         # all partner_ids of the mail.message have to be notified regardless of the above (even the author if explicitly added!)
990         if message.partner_ids:
991             partners_to_notify |= set([p.id for p in message.partner_ids])
992
993         # notify
994         notification_obj._notify(
995             cr, uid, newid, partners_to_notify=list(partners_to_notify), context=context,
996             force_send=force_send, user_signature=user_signature
997         )
998         message.refresh()
999
1000         # An error appear when a user receive a notification without notifying
1001         # the parent message -> add a read notification for the parent
1002         if message.parent_id:
1003             # all notified_partner_ids of the mail.message have to be notified for the parented messages
1004             partners_to_parent_notify = set(message.notified_partner_ids).difference(message.parent_id.notified_partner_ids)
1005             for partner in partners_to_parent_notify:
1006                 notification_obj.create(cr, uid, {
1007                         'message_id': message.parent_id.id,
1008                         'partner_id': partner.id,
1009                         'read': True,
1010                     }, context=context)
1011
1012     #------------------------------------------------------
1013     # Tools
1014     #------------------------------------------------------
1015
1016     def check_partners_email(self, cr, uid, partner_ids, context=None):
1017         """ Verify that selected partner_ids have an email_address defined.
1018             Otherwise throw a warning. """
1019         partner_wo_email_lst = []
1020         for partner in self.pool.get('res.partner').browse(cr, uid, partner_ids, context=context):
1021             if not partner.email:
1022                 partner_wo_email_lst.append(partner)
1023         if not partner_wo_email_lst:
1024             return {}
1025         warning_msg = _('The following partners chosen as recipients for the email have no email address linked :')
1026         for partner in partner_wo_email_lst:
1027             warning_msg += '\n- %s' % (partner.name)
1028         return {'warning': {
1029                     'title': _('Partners email addresses not found'),
1030                     'message': warning_msg,
1031                     }
1032                 }