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