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