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