[MERGE]: Merged with lp:openobject-addons
[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 openerp
24 import tools
25
26 from email.header import decode_header
27 from operator import itemgetter
28 from osv import osv, fields
29 from tools.translate import _
30
31 _logger = logging.getLogger(__name__)
32
33 """ Some tools for parsing / creating email fields """
34 def decode(text):
35     """Returns unicode() string conversion of the the given encoded smtp header text"""
36     if text:
37         text = decode_header(text.replace('\r', ''))
38         return ''.join([tools.ustr(x[0], x[1]) for x in text])
39
40
41 class mail_message(osv.Model):
42     """ Messages model: system notification (replacing res.log notifications),
43         comments (OpenChatter discussion) and incoming emails. """
44     _name = 'mail.message'
45     _description = 'Message'
46     _inherit = ['ir.needaction_mixin']
47     _order = 'id desc'
48
49     _message_read_limit = 10
50     _message_record_name_length = 18
51
52     def _shorten_name(self, name):
53         if len(name) <= (self._message_record_name_length + 3):
54             return name
55         return name[:self._message_record_name_length] + '...'
56
57     def _get_record_name(self, cr, uid, ids, name, arg, context=None):
58         """ Return the related document name, using get_name. """
59         result = dict.fromkeys(ids, '')
60         for message in self.browse(cr, uid, ids, context=context):
61             if not message.model or not message.res_id:
62                 continue
63             try:
64                 result[message.id] = self._shorten_name(self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1])
65             except openerp.exceptions.AccessDenied, e:
66                 pass
67         return result
68
69     def _get_unread(self, cr, uid, ids, name, arg, context=None):
70         """ Compute if the message is unread by the current user. """
71         res = dict((id, {'unread': False}) for id in ids)
72         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
73         notif_obj = self.pool.get('mail.notification')
74         notif_ids = notif_obj.search(cr, uid, [
75             ('partner_id', 'in', [partner_id]),
76             ('message_id', 'in', ids),
77             ('read', '=', False)
78         ], context=context)
79         for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
80             res[notif.message_id.id]['unread'] = True
81         return res
82
83     def _search_unread(self, cr, uid, obj, name, domain, context=None):
84         """ Search for messages unread by the current user. Condition is
85             inversed because we search unread message on a read column. """
86         if domain[0][2]:
87             read_cond = '(read = false or read is null)'
88         else:
89             read_cond = 'read = true'
90         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
91         cr.execute("SELECT message_id FROM mail_notification "\
92                         "WHERE partner_id = %%s AND %s" % read_cond,
93                     (partner_id,))
94         return [('id', 'in', [r[0] for r in cr.fetchall()])]
95
96     def name_get(self, cr, uid, ids, context=None):
97         # name_get may receive int id instead of an id list
98         if isinstance(ids, (int, long)):
99             ids = [ids]
100         res = []
101         for message in self.browse(cr, uid, ids, context=context):
102             name = '%s: %s' % (message.subject or '', message.body or '')
103             res.append((message.id, self._shorten_name(name.lstrip(' :'))))
104         return res
105
106     _columns = {
107         'type': fields.selection([
108                         ('email', 'Email'),
109                         ('comment', 'Comment'),
110                         ('notification', 'System notification'),
111                         ], 'Type',
112             help="Message type: email for email message, notification for system "\
113                  "message, comment for other messages such as user replies"),
114         'author_id': fields.many2one('res.partner', 'Author', required=True),
115         'partner_ids': fields.many2many('res.partner', 'mail_notification', 'message_id', 'partner_id', 'Recipients'),
116         'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel',
117             'message_id', 'attachment_id', 'Attachments'),
118         'parent_id': fields.many2one('mail.message', 'Parent Message', select=True, ondelete='set null', help="Initial thread message."),
119         'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
120         'model': fields.char('Related Document Model', size=128, select=1),
121         'res_id': fields.integer('Related Document ID', select=1),
122         'record_name': fields.function(_get_record_name, type='string',
123             string='Message Record Name',
124             help="Name get of the related document."),
125         'notification_ids': fields.one2many('mail.notification', 'message_id', 'Notifications'),
126         'subject': fields.char('Subject'),
127         'date': fields.datetime('Date'),
128         'message_id': fields.char('Message-Id', help='Message unique identifier', select=1, readonly=1),
129         'body': fields.html('Contents', help='Automatically sanitized HTML contents'),
130         'unread': fields.function(_get_unread, fnct_search=_search_unread,
131             type='boolean', string='Unread',
132             help='Functional field to search for unread messages linked to uid'),
133         'vote_user_ids': fields.many2many('res.users', 'mail_vote', 'message_id', 'user_id', 'Votes'),
134
135     }
136
137     def _needaction_domain_get(self, cr, uid, context=None):
138         if self._needaction:
139             return [('unread', '=', True)]
140         return []
141
142     def _get_default_author(self, cr, uid, context=None):
143         return self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
144
145     _defaults = {
146         'type': 'email',
147         'date': lambda *a: fields.datetime.now(),
148         'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
149         'body': '',
150     }
151     
152     #---------------------------------------------------
153     #Mail Vote system (Like or Unlike comments
154     #-----------------------------------------------------
155     def vote_toggle(self, cr, uid, ids, user_ids=None, context=None):
156         '''
157         Toggles when Comment is liked or unlike.
158         create vote entries if current user like comment..
159         '''
160         vote_pool = self.pool.get('mail.vote')
161         if not user_ids: user_ids = [uid]
162         for message in self.browse(cr, uid, ids, context):
163             voters_ids = [user.id for user in message.vote_user_ids if user.id == uid]
164             if not voters_ids:
165                 self.write(cr, uid, ids, {'vote_user_ids': [(4, user_id) for user_id in user_ids]}, context=context)
166             else:
167                 self.write(cr, uid, ids, {'vote_user_ids': [(3, user_id) for user_id in user_ids]}, context=context)
168         return True
169
170     #------------------------------------------------------
171     # Message loading for web interface
172     #------------------------------------------------------
173
174     def _message_dict_get(self, cr, uid, msg, context=None):
175         """ Return a dict representation of the message browse record. """
176         vote_pool = self.pool.get('mail.vote')
177         has_voted = False;
178         vote_ids = []
179         vote_ids = vote_pool.name_get(cr, uid, [user.id for user in msg.vote_user_ids], context=context)
180         for user_id in msg.vote_user_ids:
181             if (user_id.id == uid):
182                 has_voted = True;
183         attachment_ids = [{'id': attach[0], 'name': attach[1]} for attach in self.pool.get('ir.attachment').name_get(cr, uid, [x.id for x in msg.attachment_ids], context=context)]
184         author_id = self.pool.get('res.partner').name_get(cr, uid, [msg.author_id.id], context=context)[0]
185         author_user_id = self.pool.get('res.users').name_get(cr, uid, [msg.author_id.user_ids[0].id], context=context)[0]
186         partner_ids = self.pool.get('res.partner').name_get(cr, uid, [x.id for x in msg.partner_ids], context=context)
187         return {
188             'id': msg.id,
189             'type': msg.type,
190             'attachment_ids': attachment_ids,
191             'body': msg.body,
192             'model': msg.model,
193             'res_id': msg.res_id,
194             'record_name': msg.record_name,
195             'subject': msg.subject,
196             'date': msg.date,
197             'author_id': author_id,
198             'author_user_id': author_user_id,
199             'partner_ids': partner_ids,
200             'child_ids': [],
201             'vote_user_ids': vote_ids,
202             'has_voted': has_voted
203         }
204
205     def message_read_tree_flatten(self, cr, uid, messages, current_level, level, context=None):
206         """ Given a tree with several roots of following structure :
207             [   {'id': 1, 'child_ids': [
208                     {'id': 11, 'child_ids': [...] },],
209                 {...}   ]
210             Flatten it to have a maximum number of levels, 0 being flat and
211             sort messages in a level according to a key of the messages.
212             Perform the flattening at leafs if above the maximum depth, then get
213             back in the tree.
214             :param context: ``sort_key``: key for sorting (id by default)
215             :param context: ``sort_reverse``: reverser order for sorting (True by default)
216         """
217         def _flatten(msg_dict):
218             """ from    {'id': x, 'child_ids': [{child1}, {child2}]}
219                 get     [{'id': x, 'child_ids': []}, {child1}, {child2}]
220             """
221             child_ids = msg_dict.pop('child_ids', [])
222             msg_dict['child_ids'] = []
223             return [msg_dict] + child_ids
224             # return sorted([msg_dict] + child_ids, key=itemgetter('id'), reverse=True)
225         context = context or {}
226         # Depth-first flattening
227         for message in messages:
228             if message.get('type') == 'expandable':
229                 continue
230             message['child_ids'] = self.message_read_tree_flatten(cr, uid, message['child_ids'], current_level + 1, level, context=context)
231         # Flatten if above maximum depth
232         if current_level < level:
233             return_list = messages
234         else:
235             return_list = []
236             for message in messages:
237                 for flat_message in _flatten(message):
238                     return_list.append(flat_message)
239         return sorted(return_list, key=itemgetter(context.get('sort_key', 'id')), reverse=context.get('sort_reverse', True))
240
241     def message_read(self, cr, uid, ids=False, domain=[], thread_level=0, limit=None, context=None):
242         """ If IDs are provided, fetch these records. Otherwise use the domain
243             to fetch the matching records.
244             After having fetched the records provided by IDs, it will fetch the
245             parents to have well-formed threads.
246             :return list: list of trees of messages
247         """
248         limit = limit or self._message_read_limit
249         context = context or {}
250         if not ids:
251             ids = self.search(cr, uid, domain, context=context, limit=limit)
252         messages = self.browse(cr, uid, ids, context=context)
253
254         result = []
255         tree = {} # key: ID, value: record
256         for msg in messages:
257             if len(result) < (limit - 1):
258                 record = self._message_dict_get(cr, uid, msg, context=context)
259                 if thread_level and msg.parent_id:
260                     while msg.parent_id:
261                         if msg.parent_id.id in tree:
262                             record_parent = tree[msg.parent_id.id]
263                         else:
264                             record_parent = self._message_dict_get(cr, uid, msg.parent_id, context=context)
265                             if msg.parent_id.parent_id:
266                                 tree[msg.parent_id.id] = record_parent
267                         if record['id'] not in [x['id'] for x in record_parent['child_ids']]:
268                             record_parent['child_ids'].append(record)
269                         record = record_parent
270                         msg = msg.parent_id
271                 if msg.id not in tree:
272                     result.append(record)
273                     tree[msg.id] = record
274             else:
275                 result.append({
276                     'type': 'expandable',
277                     'domain': [('id', '<=', msg.id)] + domain,
278                     'context': context,
279                     'thread_level': thread_level,  # should be improve accodting to level of records
280                     'id': -1,
281                 })
282                 break
283
284         # Flatten the result
285         if thread_level > 0:
286             result = self.message_read_tree_flatten(cr, uid, result, 0, thread_level, context=context)
287         return result
288
289     #------------------------------------------------------
290     # Email api
291     #------------------------------------------------------
292
293     def init(self, cr):
294         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
295         if not cr.fetchone():
296             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
297
298     def check_access_rule(self, cr, uid, ids, operation, context=None):
299         """ mail.message access rule check
300             - message received (a notification exists) -> ok
301             - check rules of related document if exists
302             - fallback on normal mail.message check """
303         if isinstance(ids, (int, long)):
304             ids = [ids]
305
306         # check messages for which you have a notification
307         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
308         not_obj = self.pool.get('mail.notification')
309         not_ids = not_obj.search(cr, uid, [
310             ('partner_id', '=', partner_id),
311             ('message_id', 'in', ids),
312         ], context=context)
313         notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, uid, not_ids, context=context)
314             if notification.message_id.id in ids]
315
316         # check messages linked to an existing document
317         model_record_ids = {}
318         document_ids = []
319         cr.execute('SELECT DISTINCT id, model, res_id FROM mail_message WHERE id = ANY (%s)', (ids,))
320         for id, rmod, rid in cr.fetchall():
321             if not (rmod and rid):
322                 continue
323             document_ids.append(id)
324             model_record_ids.setdefault(rmod, set()).add(rid)
325         for model, mids in model_record_ids.items():
326             model_obj = self.pool.get(model)
327             mids = model_obj.exists(cr, uid, mids)
328             model_obj.check_access_rights(cr, uid, operation)
329             model_obj.check_access_rule(cr, uid, mids, operation, context=context)
330
331         # fall back on classic operation for other ids
332         other_ids = set(ids).difference(set(notified_ids), set(document_ids))
333         super(mail_message, self).check_access_rule(cr, uid, other_ids, operation, context=None)
334
335     def create(self, cr, uid, values, context=None):
336         if not values.get('message_id') and values.get('res_id') and values.get('model'):
337             values['message_id'] = tools.generate_tracking_message_id('%(model)s-%(res_id)s' % values)
338         newid = super(mail_message, self).create(cr, uid, values, context)
339         self.notify(cr, uid, newid, context=context)
340         return newid
341
342     def unlink(self, cr, uid, ids, context=None):
343         # cascade-delete attachments that are directly attached to the message (should only happen
344         # for mail.messages that act as parent for a standalone mail.mail record).
345         attachments_to_delete = []
346         for message in self.browse(cr, uid, ids, context=context):
347             for attach in message.attachment_ids:
348                 if attach.res_model == self._name and attach.res_id == message.id:
349                     attachments_to_delete.append(attach.id)
350         if attachments_to_delete:
351             self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
352         return super(mail_message, self).unlink(cr, uid, ids, context=context)
353
354     def notify(self, cr, uid, newid, context=None):
355         """ Add the related record followers to the destination partner_ids.
356             Call mail_notification.notify to manage the email sending
357         """
358         message = self.browse(cr, uid, newid, context=context)
359         partners_to_notify = set([])
360         # add all partner_ids of the message
361         if message.partner_ids:
362             partners_to_notify |= set(partner.id for partner in message.partner_ids)
363         # add all followers and set add them in partner_ids
364         if message.model and message.res_id:
365             record = self.pool.get(message.model).browse(cr, uid, message.res_id, context=context)
366             extra_notified = set(partner.id for partner in record.message_follower_ids)
367             missing_notified = extra_notified - partners_to_notify
368             if missing_notified:
369                 message.write({'partner_ids': [(4, p_id) for p_id in missing_notified]})
370             partners_to_notify |= extra_notified
371         self.pool.get('mail.notification').notify(cr, uid, list(partners_to_notify), newid, context=context)
372
373     def copy(self, cr, uid, id, default=None, context=None):
374         """Overridden to avoid duplicating fields that are unique to each email"""
375         if default is None:
376             default = {}
377         default.update(message_id=False, headers=False)
378         return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
379
380     #------------------------------------------------------
381     # Tools
382     #------------------------------------------------------
383
384     def check_partners_email(self, cr, uid, partner_ids, context=None):
385         """ Verify that selected partner_ids have an email_address defined.
386             Otherwise throw a warning. """
387         partner_wo_email_lst = []
388         for partner in self.pool.get('res.partner').browse(cr, uid, partner_ids, context=context):
389             if not partner.email:
390                 partner_wo_email_lst.append(partner)
391         if not partner_wo_email_lst:
392             return {}
393         warning_msg = _('The following partners chosen as recipients for the email have no email address linked :')
394         for partner in partner_wo_email_lst:
395             warning_msg += '\n- %s' % (partner.name)
396         return {'warning': {
397                     'title': _('Partners email addresses not found'),
398                     'message': warning_msg,
399                     }
400                 }