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 ##############################################################################
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 _
32 _logger = logging.getLogger(__name__)
34 """ Some tools for parsing / creating email fields """
36 """Returns unicode() string conversion of the the given encoded smtp header text"""
38 text = decode_header(text.replace('\r', ''))
39 return ''.join([tools.ustr(x[0], x[1]) for x in text])
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']
50 _message_read_limit = 10
51 _message_record_name_length = 18
53 def _shorten_name(self, name):
54 if len(name) <= (self._message_record_name_length + 3):
56 return name[:self._message_record_name_length] + '...'
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:
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):
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),
80 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
81 res[notif.message_id.id]['unread'] = True
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. """
88 read_cond = '(read = false or read is null)'
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,
95 return [('id', 'in', [r[0] for r in cr.fetchall()])]
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)):
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(' :'))))
108 'type': fields.selection([
110 ('comment', 'Comment'),
111 ('notification', 'System notification'),
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'),
139 def _needaction_domain_get(self, cr, uid, context=None):
141 return [('unread', '=', True)]
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
150 'date': lambda *a: fields.datetime.now(),
151 'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
155 #------------------------------------------------------
157 #------------------------------------------------------
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. '''
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')
168 self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(4, user_id)]}, context=context)
170 self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(3, user_id)]}, context=context)
171 return not(has_voted) or False
173 #------------------------------------------------------
174 # Message loading for web interface
175 #------------------------------------------------------
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).
183 child_nbr = len(msg.child_ids)
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:
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):
195 author_id = self.pool.get('res.partner').name_get(cr, uid, [msg.author_id.id], context=context)[0]
196 is_author = uid == msg.author_id.user_ids[0].id
197 except (orm.except_orm, osv.except_osv):
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):
208 'attachment_ids': attachment_ids,
211 'res_id': msg.res_id,
212 'record_name': msg.record_name,
213 'subject': msg.subject,
215 'author_id': author_id,
216 'is_author': is_author,
217 'partner_ids': partner_ids,
218 'parent_id': msg.parent_id and msg.parent_id.id or False,
219 'vote_user_ids': vote_ids,
220 'has_voted': has_voted,
221 'unread': msg.unread and msg.unread['unread'] or False
224 def message_read_tree_get_expandable(self, cr, uid, parent_message, last_message, domain=[], current_level=0, level=0, context=None):
226 base_domain = [('id', '<', last_message['id'])]
227 if parent_message and current_level < level:
228 base_domain += [('parent_id', '=', parent_message['id'])]
230 base_domain += [('id', 'child_of', parent_message['id']), ('id', '!=', parent_message['id'])]
232 base_domain += domain
233 extension = { 'type': 'expandable',
234 'domain': base_domain,
235 'thread_level': current_level,
241 def message_read_tree_flatten(self, cr, uid, parent_message, messages, domain=[], level=0, current_level=0, context=None, limit=None, add_expandable=True):
242 """ Given a tree with several roots of following structure :
243 [ {'id': 1, 'child_ids': [
244 {'id': 11, 'child_ids': [...] },],
246 Flatten it to have a maximum number of levels, 0 being flat and
247 sort messages in a level according to a key of the messages.
248 Perform the flattening at leafs if above the maximum depth, then get
250 :param context: ``sort_key``: key for sorting (id by default)
251 :param context: ``sort_reverse``: reverser order for sorting (True by default)
253 def _flatten(msg_dict):
254 """ from {'id': x, 'child_ids': [{child1}, {child2}]}
255 get [{'id': x, 'child_ids': []}, {child1}, {child2}]
257 child_ids = msg_dict.pop('child_ids', [])
258 msg_dict['child_ids'] = []
259 return [msg_dict] + child_ids
261 context = context or {}
262 limit = limit or self._message_read_limit
264 # Depth-first flattening
265 for message in messages:
266 if message.get('type') == 'expandable':
268 message['child_ids'] = self.message_read_tree_flatten(cr, uid, message, message['child_ids'], domain, level, current_level + 1, context=context, limit=limit)
269 for child in message['child_ids']:
270 if child.get('type') == 'expandable':
272 message['child_nbr'] += child['child_nbr']
273 # Flatten if above maximum depth
274 if current_level < level:
275 return_list = messages
277 return_list = [flat_message for message in messages for flat_message in _flatten(message)]
280 return_list = sorted(return_list, key=itemgetter(context.get('sort_key', 'id')), reverse=context.get('sort_reverse', True))
281 if return_list and current_level == 0 and add_expandable:
282 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)
283 return_list.append(expandable)
284 elif return_list and current_level <= level and add_expandable:
285 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)
286 return_list.append(expandable)
289 def message_read(self, cr, uid, ids=False, domain=[], level=0, context=None, parent_id=False, limit=None):
290 """ Read messages from mail.message, and get back a structured tree
291 of messages to be displayed as discussion threads. If IDs is set,
292 fetch these records. Otherwise use the domain to fetch messages.
293 After having fetch messages, their parents will be added to obtain
296 :param domain: optional domain for searching ids
297 :param level: level of threads to display, 0 being flat
298 :param limit: number of messages to fetch
299 :param parent_id: if parent_id reached, stop searching for
301 :return list: list of trees of messages
304 message_loaded = context and context.get('message_loaded') or [0]
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',message_loaded] ];
310 limit = limit or self._message_read_limit
311 context = context or {}
319 for msg in self.browse(cr, uid, ids, context=context):
320 result.append(self._message_dict_get(cr, uid, msg, context=context))
323 # key: ID, value: record
324 ids = self.search(cr, SUPERUSER_ID, domain, context=context, limit=limit)
325 for msg in self.browse(cr, uid, ids, context=context):
326 # if not in record and not in message_loded list
327 if msg.id not in tree and msg.id not in message_loaded :
328 record = self._message_dict_get(cr, uid, msg, context=context)
330 result.append(record)
332 while msg.parent_id and msg.parent_id.id != parent_id:
333 parent_id = msg.parent_id.id
334 if msg.parent_id.id not in tree:
337 # if not in record and not in message_loded list
338 if msg.id not in message_loaded :
339 record = self._message_dict_get(cr, uid, msg, context=context)
340 result.append(record)
342 result = sorted(result, key=lambda k: k['id'])
346 # expandable for not show message
349 not_loaded_ids = self.search(cr, SUPERUSER_ID, [['parent_id','=',id_msg],['id','not in',message_loaded]], None, limit=1000)
350 # group childs not read
354 for not_loaded_id in not_loaded_ids:
355 if not_loaded_id not in tree:
357 if id_min==None or id_min>not_loaded_id:
359 if id_max==None or id_max<not_loaded_id:
361 tree_not.append(not_loaded_id)
365 'domain': [['id','>=',id_min],['id','<=',id_max],['parent_id','=',id_msg]],
367 'type': 'expandable',
374 'domain': [['id','>=',id_min],['parent_id','=',id_msg]],
376 'type': 'expandable',
382 # expandable for limit max
383 ids = self.search(cr, SUPERUSER_ID, domain+[['id','not in',message_loaded+tree+tree_not]], context=context, limit=1)
389 'type': 'expandable',
390 'parent_id': parent_id,
395 result = sorted(result, key=lambda k: k['id'])
399 #------------------------------------------------------
401 #------------------------------------------------------
404 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
405 if not cr.fetchone():
406 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
408 def check_access_rule(self, cr, uid, ids, operation, context=None):
409 """ Access rules of mail.message:
411 - notification exist (I receive pushed message) OR
412 - author_id = pid (I am the author) OR
413 - I can read the related document if res_model, res_id
416 - I am in the document message_follower_ids OR
417 - I can write on the related document if res_model, res_id
420 - I can write on the related document if res_model, res_id
423 - I can write on the related document if res_model, res_id
426 if uid == SUPERUSER_ID:
428 if isinstance(ids, (int, long)):
430 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=None)['partner_id'][0]
432 # Read mail_message.ids to have their values
433 model_record_ids = {}
434 message_values = dict.fromkeys(ids)
435 cr.execute('SELECT DISTINCT id, model, res_id, author_id FROM "%s" WHERE id = ANY (%%s)' % self._table, (ids,))
436 for id, rmod, rid, author_id in cr.fetchall():
437 message_values[id] = {'res_model': rmod, 'res_id': rid, 'author_id': author_id}
439 model_record_ids.setdefault(rmod, set()).add(rid)
441 # Read: Check for received notifications -> could become an ir.rule, but not till we do not have a many2one variable field
442 if operation == 'read':
443 not_obj = self.pool.get('mail.notification')
444 not_ids = not_obj.search(cr, SUPERUSER_ID, [
445 ('partner_id', '=', partner_id),
446 ('message_id', 'in', ids),
448 notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, SUPERUSER_ID, not_ids, context=context)]
451 # Read: Check messages you are author -> could become an ir.rule, but not till we do not have a many2one variable field
452 if operation == 'read':
453 author_ids = [mid for mid, message in message_values.iteritems()
454 if message.get('author_id') and message.get('author_id') == partner_id]
458 # Create: Check message_follower_ids
459 if operation == 'create':
460 doc_follower_ids = []
461 for model, mids in model_record_ids.items():
462 fol_obj = self.pool.get('mail.followers')
463 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [
464 ('res_model', '=', model),
465 ('res_id', 'in', list(mids)),
466 ('partner_id', '=', partner_id),
468 fol_mids = [follower.res_id for follower in fol_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context)]
469 doc_follower_ids += [mid for mid, message in message_values.iteritems()
470 if message.get('res_model') == model and message.get('res_id') in fol_mids]
472 doc_follower_ids = []
474 # Calculate remaining ids, and related model/res_ids
475 model_record_ids = {}
476 other_ids = set(ids).difference(set(notified_ids), set(author_ids), set(doc_follower_ids))
478 if message_values[id]['res_model']:
479 model_record_ids.setdefault(message_values[id]['res_model'], set()).add(message_values[id]['res_id'])
481 # CRUD: Access rights related to the document
482 document_related_ids = []
483 for model, mids in model_record_ids.items():
484 model_obj = self.pool.get(model)
485 mids = model_obj.exists(cr, uid, mids)
486 if operation in ['create', 'write', 'unlink']:
487 model_obj.check_access_rights(cr, uid, 'write')
488 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
490 model_obj.check_access_rights(cr, uid, operation)
491 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
492 document_related_ids += [mid for mid, message in message_values.iteritems()
493 if message.get('res_model') == model and message.get('res_id') in mids]
495 # Calculate remaining ids: if not void, raise an error
496 other_ids = set(ids).difference(set(notified_ids), set(author_ids), set(doc_follower_ids), set(document_related_ids))
499 raise orm.except_orm(_('Access Denied'),
500 _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % \
501 (self._description, operation))
503 def create(self, cr, uid, values, context=None):
504 if not values.get('message_id') and values.get('res_id') and values.get('model'):
505 values['message_id'] = tools.generate_tracking_message_id('%(model)s-%(res_id)s' % values)
506 newid = super(mail_message, self).create(cr, uid, values, context)
507 self._notify(cr, 1, newid, context=context)
510 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
511 """ Override to explicitely call check_access_rule, that is not called
512 by the ORM. It instead directly fetches ir.rules and apply them. """
513 res = super(mail_message, self).read(cr, uid, ids, fields=fields, context=context, load=load)
514 self.check_access_rule(cr, uid, ids, 'read', context=context)
517 def unlink(self, cr, uid, ids, context=None):
518 # cascade-delete attachments that are directly attached to the message (should only happen
519 # for mail.messages that act as parent for a standalone mail.mail record).
520 attachments_to_delete = []
521 for message in self.browse(cr, uid, ids, context=context):
522 for attach in message.attachment_ids:
523 if attach.res_model == self._name and attach.res_id == message.id:
524 attachments_to_delete.append(attach.id)
525 if attachments_to_delete:
526 self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
527 return super(mail_message, self).unlink(cr, uid, ids, context=context)
529 def _notify(self, cr, uid, newid, context=None):
530 """ Add the related record followers to the destination partner_ids.
531 Call mail_notification.notify to manage the email sending
533 message = self.browse(cr, uid, newid, context=context)
534 partners_to_notify = set([])
535 # message has no subtype_id: pure log message -> no partners, no one notified
536 if not message.subtype_id:
537 message.write({'partner_ids': [5]})
539 # all partner_ids of the mail.message have to be notified
540 if message.partner_ids:
541 partners_to_notify |= set(partner.id for partner in message.partner_ids)
542 # all followers of the mail.message document have to be added as partners and notified
543 if message.model and message.res_id:
544 fol_obj = self.pool.get("mail.followers")
545 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)
546 fol_objs = fol_obj.browse(cr, uid, fol_ids, context=context)
547 extra_notified = set(fol.partner_id.id for fol in fol_objs)
548 missing_notified = extra_notified - partners_to_notify
549 missing_notified = missing_notified
551 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(4, p_id) for p_id in missing_notified]}, context=context)
552 partners_to_notify |= extra_notified
554 # add myself if I wrote on my wall,
555 # unless remove myself author
556 if ((message.model=="res.partner" and message.res_id==message.author_id.id)):
557 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(4, message.author_id.id)]}, context=context)
559 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(3, message.author_id.id)]}, context=context)
561 self.pool.get('mail.notification')._notify(cr, uid, newid, context=context)
563 def copy(self, cr, uid, id, default=None, context=None):
564 """Overridden to avoid duplicating fields that are unique to each email"""
567 default.update(message_id=False, headers=False)
568 return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
570 #------------------------------------------------------
572 #------------------------------------------------------
574 def check_partners_email(self, cr, uid, partner_ids, context=None):
575 """ Verify that selected partner_ids have an email_address defined.
576 Otherwise throw a warning. """
577 partner_wo_email_lst = []
578 for partner in self.pool.get('res.partner').browse(cr, uid, partner_ids, context=context):
579 if not partner.email:
580 partner_wo_email_lst.append(partner)
581 if not partner_wo_email_lst:
583 warning_msg = _('The following contacts do not have an email address specified.')
584 for partner in partner_wo_email_lst:
585 warning_msg += '\n- %s' % (partner.name)
587 'title': _('Email not found'),
588 'message': warning_msg,