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