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