1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2010-today OpenERP SA (<http://www.openerp.com>)
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
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
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/>
20 ##############################################################################
25 from email.header import decode_header
26 from operator import itemgetter
27 from osv import osv, fields
29 _logger = logging.getLogger(__name__)
31 """ Some tools for parsing / creating email fields """
33 """Returns unicode() string conversion of the the given encoded smtp header text"""
35 text = decode_header(text.replace('\r', ''))
36 return ''.join([tools.ustr(x[0], x[1]) for x in text])
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']
46 _message_read_limit = 10
47 _message_record_name_length = 18
49 def _shorten_name(self, name):
50 if len(name) <= (self._message_record_name_length + 3):
52 return name[:self._message_record_name_length] + '...'
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:
60 result[message.id] = self._shorten_name(self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1])
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),
73 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
74 res[notif.message_id.id]['unread'] = True
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. """
81 read_cond = '(read = false or read is null)'
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,
88 return [('id', 'in', [r[0] for r in cr.fetchall()])]
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)):
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(' :'))))
101 'type': fields.selection([
103 ('comment', 'Comment'),
104 ('notification', 'System notification'),
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'),
130 def _needaction_domain_get(self, cr, uid, context=None):
132 return [('unread', '=', True)]
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
140 'date': lambda *a: fields.datetime.now(),
141 'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
145 #------------------------------------------------------
146 # Message loading for web interface
147 #------------------------------------------------------
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)
158 'attachment_ids': attachment_ids,
161 'res_id': msg.res_id,
162 'record_name': msg.record_name,
163 'subject': msg.subject,
165 'author_id': author_id,
166 'author_user_id': author_user_id,
167 'partner_ids': partner_ids,
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': [...] },],
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
180 :param context: ``sort_key``: key for sorting (id by default)
181 :param context: ``sort_reverse``: reverser order for sorting (True by default)
183 def _flatten(msg_dict):
184 """ from {'id': x, 'child_ids': [{child1}, {child2}]}
185 get [{'id': x, 'child_ids': []}, {child1}, {child2}]
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':
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
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))
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
214 limit = limit or self._message_read_limit
215 context = context or {}
217 ids = self.search(cr, uid, domain, context=context, limit=limit)
218 messages = self.browse(cr, uid, ids, context=context)
221 tree = {} # key: ID, value: record
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:
227 if msg.parent_id.id in tree:
228 record_parent = tree[msg.parent_id.id]
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
237 if msg.id not in tree:
238 result.append(record)
239 tree[msg.id] = record
242 'type': 'expandable',
243 'domain': [('id', '<=', msg.id)] + domain,
245 'thread_level': thread_level, # should be improve accodting to level of records
252 result = self.message_read_tree_flatten(cr, uid, result, 0, thread_level, context=context)
255 #------------------------------------------------------
257 #------------------------------------------------------
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)""")
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)):
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),
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]
282 # check messages linked to an existing document
283 model_record_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):
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)
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)
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)
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)
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
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)
348 def copy(self, cr, uid, id, default=None, context=None):
349 """Overridden to avoid duplicating fields that are unique to each email"""
352 default.update(message_id=False, headers=False)
353 return super(mail_message, self).copy(cr, uid, id, default=default, context=context)