[MERGE] mail/chatter complete review/refactoring
[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     }
128
129     def _needaction_domain_get(self, cr, uid, context=None):
130         if self._needaction:
131             return [('unread', '=', True)]
132         return []
133
134     def _get_default_author(self, cr, uid, context=None):
135         return self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
136
137     _defaults = {
138         'type': 'email',
139         'date': lambda *a: fields.datetime.now(),
140         'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
141         'body': '',
142     }
143
144     #------------------------------------------------------
145     # Message loading for web interface
146     #------------------------------------------------------
147
148     def _message_dict_get(self, cr, uid, msg, context=None):
149         """ Return a dict representation of the message browse record. """
150         attachment_ids = self.pool.get('ir.attachment').name_get(cr, uid, [x.id for x in msg.attachment_ids], context=context)
151         author_id = self.pool.get('res.partner').name_get(cr, uid, [msg.author_id.id], context=context)[0]
152         author_user_id = self.pool.get('res.users').name_get(cr, uid, [msg.author_id.user_ids[0].id], context=context)[0]
153         partner_ids = self.pool.get('res.partner').name_get(cr, uid, [x.id for x in msg.partner_ids], context=context)
154         return {
155             'id': msg.id,
156             'type': msg.type,
157             'attachment_ids': attachment_ids,
158             'body': msg.body,
159             'model': msg.model,
160             'res_id': msg.res_id,
161             'record_name': msg.record_name,
162             'subject': msg.subject,
163             'date': msg.date,
164             'author_id': author_id,
165             'author_user_id': author_user_id,
166             'partner_ids': partner_ids,
167             'child_ids': [],
168         }
169
170     def message_read_tree_flatten(self, cr, uid, messages, current_level, level, context=None):
171         """ Given a tree with several roots of following structure :
172             [   {'id': 1, 'child_ids': [
173                     {'id': 11, 'child_ids': [...] },],
174                 {...}   ]
175             Flatten it to have a maximum number of levels, 0 being flat and
176             sort messages in a level according to a key of the messages.
177             Perform the flattening at leafs if above the maximum depth, then get
178             back in the tree.
179             :param context: ``sort_key``: key for sorting (id by default)
180             :param context: ``sort_reverse``: reverser order for sorting (True by default)
181         """
182         def _flatten(msg_dict):
183             """ from    {'id': x, 'child_ids': [{child1}, {child2}]}
184                 get     [{'id': x, 'child_ids': []}, {child1}, {child2}]
185             """
186             child_ids = msg_dict.pop('child_ids', [])
187             msg_dict['child_ids'] = []
188             return [msg_dict] + child_ids
189             # return sorted([msg_dict] + child_ids, key=itemgetter('id'), reverse=True)
190         context = context or {}
191         # Depth-first flattening
192         for message in messages:
193             if message.get('type') == 'expandable':
194                 continue
195             message['child_ids'] = self.message_read_tree_flatten(cr, uid, message['child_ids'], current_level + 1, level, context=context)
196         # Flatten if above maximum depth
197         if current_level < level:
198             return_list = messages
199         else:
200             return_list = []
201             for message in messages:
202                 for flat_message in _flatten(message):
203                     return_list.append(flat_message)
204         return sorted(return_list, key=itemgetter(context.get('sort_key', 'id')), reverse=context.get('sort_reverse', True))
205
206     def message_read(self, cr, uid, ids=False, domain=[], thread_level=0, limit=None, context=None):
207         """ If IDs are provided, fetch these records. Otherwise use the domain
208             to fetch the matching records.
209             After having fetched the records provided by IDs, it will fetch the
210             parents to have well-formed threads.
211             :return list: list of trees of messages
212         """
213         limit = limit or self._message_read_limit
214         context = context or {}
215         if not ids:
216             ids = self.search(cr, uid, domain, context=context, limit=limit)
217         messages = self.browse(cr, uid, ids, context=context)
218
219         result = []
220         tree = {} # key: ID, value: record
221         for msg in messages:
222             if len(result) < (limit - 1):
223                 record = self._message_dict_get(cr, uid, msg, context=context)
224                 if thread_level and msg.parent_id:
225                     while msg.parent_id:
226                         if msg.parent_id.id in tree:
227                             record_parent = tree[msg.parent_id.id]
228                         else:
229                             record_parent = self._message_dict_get(cr, uid, msg.parent_id, context=context)
230                             if msg.parent_id.parent_id:
231                                 tree[msg.parent_id.id] = record_parent
232                         if record['id'] not in [x['id'] for x in record_parent['child_ids']]:
233                             record_parent['child_ids'].append(record)
234                         record = record_parent
235                         msg = msg.parent_id
236                 if msg.id not in tree:
237                     result.append(record)
238                     tree[msg.id] = record
239             else:
240                 result.append({
241                     'type': 'expandable',
242                     'domain': [('id', '<=', msg.id)] + domain,
243                     'context': context,
244                     'thread_level': thread_level,  # should be improve accodting to level of records
245                     'id': -1,
246                 })
247                 break
248
249         # Flatten the result
250         if thread_level > 0:
251             result = self.message_read_tree_flatten(cr, uid, result, 0, thread_level, context=context)
252         return result
253
254     #------------------------------------------------------
255     # Email api
256     #------------------------------------------------------
257
258     def init(self, cr):
259         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
260         if not cr.fetchone():
261             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
262
263     def check_access_rule(self, cr, uid, ids, operation, context=None):
264         """ mail.message access rule check
265             - message received (a notification exists) -> ok
266             - check rules of related document if exists
267             - fallback on normal mail.message check """
268         if isinstance(ids, (int, long)):
269             ids = [ids]
270
271         # check messages for which you have a notification
272         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
273         not_obj = self.pool.get('mail.notification')
274         not_ids = not_obj.search(cr, uid, [
275             ('partner_id', '=', partner_id),
276             ('message_id', 'in', ids),
277         ], context=context)
278         notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, uid, not_ids, context=context)
279             if notification.message_id.id in ids]
280
281         # check messages linked to an existing document
282         model_record_ids = {}
283         document_ids = []
284         cr.execute('SELECT DISTINCT id, model, res_id FROM mail_message WHERE id = ANY (%s)', (ids,))
285         for id, rmod, rid in cr.fetchall():
286             if not (rmod and rid):
287                 continue
288             document_ids.append(id)
289             model_record_ids.setdefault(rmod, set()).add(rid)
290         for model, mids in model_record_ids.items():
291             model_obj = self.pool.get(model)
292             mids = model_obj.exists(cr, uid, mids)
293             model_obj.check_access_rights(cr, uid, operation)
294             model_obj.check_access_rule(cr, uid, mids, operation, context=context)
295
296         # fall back on classic operation for other ids
297         other_ids = set(ids).difference(set(notified_ids), set(document_ids))
298         super(mail_message, self).check_access_rule(cr, uid, other_ids, operation, context=None)
299
300     def create(self, cr, uid, values, context=None):
301         if not values.get('message_id') and values.get('res_id') and values.get('model'):
302             values['message_id'] = tools.generate_tracking_message_id('%(model)s-%(res_id)s' % values)
303         newid = super(mail_message, self).create(cr, uid, values, context)
304         self.notify(cr, uid, newid, context=context)
305         return newid
306
307     def unlink(self, cr, uid, ids, context=None):
308         # cascade-delete attachments that are directly attached to the message (should only happen
309         # for mail.messages that act as parent for a standalone mail.mail record.
310         attachments_to_delete = []
311         for mail in self.browse(cr, uid, ids, context=context):
312             for attach in mail.attachment_ids:
313                 if attach.res_model == 'mail.message' and attach.res_id == mail.id:
314                     attachments_to_delete.append(attach.id)
315         if attachments_to_delete:
316             self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
317         return super(mail_message,self).unlink(cr, uid, ids, context=context)
318
319     def notify(self, cr, uid, newid, context=None):
320         """ Add the related record followers to the destination partner_ids.
321             Call mail_notification.notify to manage the email sending
322         """
323         message = self.browse(cr, uid, newid, context=context)
324         partners_to_notify = set([])
325         # add all partner_ids of the message
326         if message.partner_ids:
327             partners_to_notify |= set(partner.id for partner in message.partner_ids)
328         # add all followers and set add them in partner_ids
329         if message.model and message.res_id:
330             record = self.pool.get(message.model).browse(cr, uid, message.res_id, context=context)
331             extra_notified = set(partner.id for partner in record.message_follower_ids)
332             missing_notified = extra_notified - partners_to_notify
333             if missing_notified:
334                 message.write({'partner_ids': [(4, p_id) for p_id in missing_notified]})
335             partners_to_notify |= extra_notified
336         self.pool.get('mail.notification').notify(cr, uid, list(partners_to_notify), newid, context=context)
337
338     def copy(self, cr, uid, id, default=None, context=None):
339         """Overridden to avoid duplicating fields that are unique to each email"""
340         if default is None:
341             default = {}
342         default.update(message_id=False, headers=False)
343         return super(mail_message, self).copy(cr, uid, id, default=default, context=context)