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 openerp import SUPERUSER_ID
27 from osv import osv, orm, fields
28 from tools.translate import _
30 _logger = logging.getLogger(__name__)
32 """ Some tools for parsing / creating email fields """
34 """Returns unicode() string conversion of the the given encoded smtp header text"""
36 text = decode_header(text.replace('\r', ''))
37 return ''.join([tools.ustr(x[0], x[1]) for x in text])
40 class mail_message(osv.Model):
41 """ Messages model: system notification (replacing res.log notifications),
42 comments (OpenChatter discussion) and incoming emails. """
43 _name = 'mail.message'
44 _description = 'Message'
45 _inherit = ['ir.needaction_mixin']
48 _message_read_limit = 10
49 _message_read_fields = ['id', 'parent_id', 'model', 'res_id', 'body', 'subject', 'date', 'to_read',
50 'type', 'vote_user_ids', 'attachment_ids', 'author_id', 'partner_ids', 'record_name', 'favorite_user_ids']
51 _message_record_name_length = 18
52 _message_read_more_limit = 1024
54 def _shorten_name(self, name):
55 if len(name) <= (self._message_record_name_length + 3):
57 return name[:self._message_record_name_length] + '...'
59 def _get_record_name(self, cr, uid, ids, name, arg, context=None):
60 """ Return the related document name, using name_get. It is included in
61 a try/except statement, because if uid cannot read the related
62 document, he should see a void string instead of crashing. """
63 result = dict.fromkeys(ids, False)
64 for message in self.read(cr, uid, ids, ['model', 'res_id'], context=context):
65 if not message['model'] or not message['res_id']:
68 result[message['id']] = self._shorten_name(self.pool.get(message['model']).name_get(cr, uid, [message['res_id']], context=context)[0][1])
69 except (orm.except_orm, osv.except_osv):
73 def _get_to_read(self, cr, uid, ids, name, arg, context=None):
74 """ Compute if the message is unread by the current user. """
75 res = dict((id, False) for id in ids)
76 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
77 notif_obj = self.pool.get('mail.notification')
78 notif_ids = notif_obj.search(cr, uid, [
79 ('partner_id', 'in', [partner_id]),
80 ('message_id', 'in', ids),
83 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
84 res[notif.message_id.id] = not notif.read
87 def _search_to_read(self, cr, uid, obj, name, domain, context=None):
88 """ Search for messages to read by the current user. Condition is
89 inversed because we search unread message on a read column. """
91 read_cond = "(read = False OR read IS NULL)"
93 read_cond = "read = True"
94 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
95 cr.execute("SELECT message_id FROM mail_notification "\
96 "WHERE partner_id = %%s AND %s" % read_cond,
98 return [('id', 'in', [r[0] for r in cr.fetchall()])]
100 def name_get(self, cr, uid, ids, context=None):
101 # name_get may receive int id instead of an id list
102 if isinstance(ids, (int, long)):
105 for message in self.browse(cr, uid, ids, context=context):
106 name = '%s: %s' % (message.subject or '', message.body or '')
107 res.append((message.id, self._shorten_name(name.lstrip(' :'))))
111 'type': fields.selection([
113 ('comment', 'Comment'),
114 ('notification', 'System notification'),
116 help="Message type: email for email message, notification for system "\
117 "message, comment for other messages such as user replies"),
118 'author_id': fields.many2one('res.partner', 'Author', required=True),
119 'partner_ids': fields.many2many('res.partner', 'mail_notification', 'message_id', 'partner_id', 'Recipients'),
120 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel',
121 'message_id', 'attachment_id', 'Attachments'),
122 'parent_id': fields.many2one('mail.message', 'Parent Message', select=True, ondelete='set null', help="Initial thread message."),
123 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
124 'model': fields.char('Related Document Model', size=128, select=1),
125 'res_id': fields.integer('Related Document ID', select=1),
126 'record_name': fields.function(_get_record_name, type='string',
127 string='Message Record Name',
128 help="Name get of the related document."),
129 'notification_ids': fields.one2many('mail.notification', 'message_id', 'Notifications'),
130 'subject': fields.char('Subject'),
131 'date': fields.datetime('Date'),
132 'message_id': fields.char('Message-Id', help='Message unique identifier', select=1, readonly=1),
133 'body': fields.html('Contents', help='Automatically sanitized HTML contents'),
134 'to_read': fields.function(_get_to_read, fnct_search=_search_to_read,
135 type='boolean', string='To read',
136 help='Functional field to search for messages the current user has to read'),
137 'subtype_id': fields.many2one('mail.message.subtype', 'Subtype'),
138 'vote_user_ids': fields.many2many('res.users', 'mail_vote',
139 'message_id', 'user_id', string='Votes',
140 help='Users that voted for this message'),
141 'favorite_user_ids': fields.many2many('res.users', 'mail_favorite',
142 'message_id', 'user_id', string='Favorite',
143 help='Users that set this message in their favorites'),
146 def _needaction_domain_get(self, cr, uid, context=None):
148 return [('to_read', '=', True)]
151 def _get_default_author(self, cr, uid, context=None):
152 return self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
156 'date': lambda *a: fields.datetime.now(),
157 'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
161 #------------------------------------------------------
163 #------------------------------------------------------
165 def vote_toggle(self, cr, uid, ids, context=None):
166 ''' Toggles vote. Performed using read to avoid access rights issues.
167 Done as SUPERUSER_ID because uid may vote for a message he cannot modify. '''
168 for message in self.read(cr, uid, ids, ['vote_user_ids'], context=context):
169 new_has_voted = not (uid in message.get('vote_user_ids'))
171 self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(4, uid)]}, context=context)
173 self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(3, uid)]}, context=context)
174 return new_has_voted or False
176 #------------------------------------------------------
178 #------------------------------------------------------
180 def favorite_toggle(self, cr, uid, ids, context=None):
181 ''' Toggles favorite. Performed using read to avoid access rights issues.
182 Done as SUPERUSER_ID because uid may star a message he cannot modify. '''
183 for message in self.read(cr, uid, ids, ['favorite_user_ids'], context=context):
184 new_is_favorite = not (uid in message.get('favorite_user_ids'))
186 self.write(cr, SUPERUSER_ID, message.get('id'), {'favorite_user_ids': [(4, uid)]}, context=context)
188 self.write(cr, SUPERUSER_ID, message.get('id'), {'favorite_user_ids': [(3, uid)]}, context=context)
189 return new_is_favorite or False
191 #------------------------------------------------------
192 # Message loading for web interface
193 #------------------------------------------------------
195 def _message_get_dict(self, cr, uid, message, context=None):
196 """ Return a dict representation of the message. This representation is
197 used in the JS client code, to display the messages.
199 :param dict message: read result of a mail.message
202 if uid in message['vote_user_ids']:
206 if uid in message['favorite_user_ids']:
210 attachment_ids = [{'id': attach[0], 'name': attach[1]} for attach in self.pool.get('ir.attachment').name_get(cr, uid, message['attachment_ids'], context=context)]
211 except (orm.except_orm, osv.except_osv):
215 partner_ids = self.pool.get('res.partner').name_get(cr, uid, message['partner_ids'], context=context)
216 except (orm.except_orm, osv.except_osv):
221 'type': message['type'],
222 'attachment_ids': attachment_ids,
223 'body': message['body'],
224 'model': message['model'],
225 'res_id': message['res_id'],
226 'record_name': message['record_name'],
227 'subject': message['subject'],
228 'date': message['date'],
229 'author_id': message['author_id'],
230 'is_author': message['author_id'] and message['author_id'][0] == uid,
231 # TDE note: is this useful ? to check
232 'partner_ids': partner_ids,
233 'parent_id': message['parent_id'] and message['parent_id'][0] or False,
234 # TDE note: see with CHM about votes, how they are displayed (only number, or name_get ?)
235 # vote: should only use number of votes
236 'vote_nb': len(message['vote_user_ids']),
237 'has_voted': has_voted,
238 'is_private': message['model'] and message['res_id'],
239 'is_favorite': is_favorite,
240 'to_read': message['to_read'],
243 def _message_read_expandable(self, cr, uid, message_list, read_messages,
244 message_loaded_ids=[], domain=[], context=None, parent_id=False, limit=None):
245 """ Create the expandable message for all parent message read
246 this function is used by message_read
248 :param list message_list: list of messages given by message_read to
249 which we have to add expandables
250 :param dict read_messages: dict [id]: read result of the messages to
251 easily have access to their values, given their ID
253 # sort for group items / TDE: move to message_read
254 # result = sorted(result, key=lambda k: k['id'])
256 # expandable for not show message
257 id_list = sorted(read_messages.keys())
258 for message_id in id_list:
259 message = read_messages[message_id]
261 # TDE note: check search is correctly implemented in mail.message
262 not_loaded_ids = self.search(cr, uid, [
263 ('parent_id', '=', message['id']),
264 ('id', 'not in', message_loaded_ids),
265 ], context=context, limit=self._message_read_more_limit)
266 # group childs not read
271 for not_loaded_id in not_loaded_ids:
272 if not read_messages.get(not_loaded_id):
274 if id_min == None or id_min > not_loaded_id:
275 id_min = not_loaded_id
276 if id_max == None or id_max < not_loaded_id:
277 id_max = not_loaded_id
278 tree_not.append(not_loaded_id)
281 message_list.append({
282 'domain': [('id', '>=', id_min), ('id', '<=', id_max), ('parent_id', '=', message_id)],
284 'type': 'expandable',
285 'parent_id': message_id,
287 'model': message['model']
293 message_list.append({
294 'domain': [('id', '>=', id_min), ('id', '<=', id_max), ('parent_id', '=', message_id)],
296 'type': 'expandable',
297 'parent_id': message_id,
299 'model': message['model'],
302 for msg_id in read_messages.keys() + tree_not:
303 message_loaded_ids.append(msg_id)
305 # expandable for limit max
306 ids = self.search(cr, uid, domain + [('id', 'not in', message_loaded_ids)], context=context, limit=1)
308 message_list.append({
311 'type': 'expandable',
312 'parent_id': parent_id,
319 def _get_parent(self, cr, uid, message, context=None):
320 """ Tools method that tries to get the parent of a mail.message. If
321 no parent, or if uid has no access right on the parent, False
324 :param dict message: read result of a mail.message
326 if not message['parent_id']:
328 parent_id = message['parent_id'][0]
330 return self.read(cr, uid, parent_id, self._message_read_fields, context=context)
331 except (orm.except_orm, osv.except_osv):
334 def message_read(self, cr, uid, ids=False, domain=[], message_loaded_ids=[], context=None, parent_id=False, limit=None):
335 """ Read messages from mail.message, and get back a structured tree
336 of messages to be displayed as discussion threads. If IDs is set,
337 fetch these records. Otherwise use the domain to fetch messages.
338 After having fetch messages, their parents & child will be added to obtain
341 TDE note: update this comment after final method implementation
343 :param domain: optional domain for searching ids
344 :param limit: number of messages to fetch
345 :param parent_id: if parent_id reached, stop searching for
347 :return list: list of trees of messages
349 if message_loaded_ids:
350 domain += [('id', 'not in', message_loaded_ids)]
351 limit = limit or self._message_read_limit
355 # specific IDs given: fetch those ids and return directly the message list
357 for message in self.read(cr, uid, ids, self._message_read_fields, context=context):
358 message_list.append(self._message_get_dict(cr, uid, message, context=context))
359 message_list = sorted(message_list, key=lambda k: k['id'])
362 # TDE FIXME: check access rights on search are implemented for mail.message
363 # fetch messages according to the domain, add their parents if uid has access to
364 ids = self.search(cr, uid, domain, context=context, limit=limit)
365 for message in self.read(cr, uid, ids, self._message_read_fields, context=context):
366 # if not in tree and not in message_loded list
367 if not read_messages.get(message.get('id')) and message.get('id') not in message_loaded_ids:
368 read_messages[message.get('id')] = message
369 message_list.append(self._message_get_dict(cr, uid, message, context=context))
371 # get all parented message if the user have the access
372 parent = self._get_parent(cr, uid, message, context=context)
373 while parent and parent.get('id') != parent_id:
374 if not read_messages.get(parent.get('id')) and parent.get('id') not in message_loaded_ids:
375 read_messages[parent.get('id')] = parent
376 message_list.append(self._message_get_dict(cr, uid, parent, context=context))
377 parent = self._get_parent(cr, uid, parent, context=context)
379 # get the child expandable messages for the tree
380 message_list = sorted(message_list, key=lambda k: k['id'])
381 message_list = self._message_read_expandable(cr, uid, message_list, read_messages,
382 message_loaded_ids=message_loaded_ids, domain=domain, context=context, parent_id=parent_id, limit=limit)
384 # message_list = sorted(message_list, key=lambda k: k['id'])
387 # TDE Note: do we need this ?
388 # def user_free_attachment(self, cr, uid, context=None):
389 # attachment = self.pool.get('ir.attachment')
390 # attachment_list = []
391 # attachment_ids = attachment.search(cr, uid, [('res_model', '=', 'mail.message'), ('create_uid', '=', uid)])
392 # if len(attachment_ids):
393 # attachment_list = [{'id': attach.id, 'name': attach.name, 'date': attach.create_date} for attach in attachment.browse(cr, uid, attachment_ids, context=context)]
394 # return attachment_list
396 #------------------------------------------------------
398 #------------------------------------------------------
401 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
402 if not cr.fetchone():
403 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
405 def check_access_rule(self, cr, uid, ids, operation, context=None):
406 """ Access rules of mail.message:
408 - notification exist (I receive pushed message) OR
409 - author_id = pid (I am the author) OR
410 - I can read the related document if res_model, res_id
413 - I am in the document message_follower_ids OR
414 - I can write on the related document if res_model, res_id OR
415 - I create a private message (no model, no res_id)
418 - I can write on the related document if res_model, res_id
421 - I can write on the related document if res_model, res_id
424 if uid == SUPERUSER_ID:
426 if isinstance(ids, (int, long)):
428 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=None)['partner_id'][0]
430 # Read mail_message.ids to have their values
431 model_record_ids = {}
432 message_values = dict.fromkeys(ids)
433 cr.execute('SELECT DISTINCT id, model, res_id, author_id FROM "%s" WHERE id = ANY (%%s)' % self._table, (ids,))
434 for id, rmod, rid, author_id in cr.fetchall():
435 message_values[id] = {'res_model': rmod, 'res_id': rid, 'author_id': author_id}
437 model_record_ids.setdefault(rmod, set()).add(rid)
439 # Read: Check for received notifications -> could become an ir.rule, but not till we do not have a many2one variable field
440 if operation == 'read':
441 not_obj = self.pool.get('mail.notification')
442 not_ids = not_obj.search(cr, SUPERUSER_ID, [
443 ('partner_id', '=', partner_id),
444 ('message_id', 'in', ids),
446 notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, SUPERUSER_ID, not_ids, context=context)]
449 # Read: Check messages you are author -> could become an ir.rule, but not till we do not have a many2one variable field
450 if operation == 'read':
451 author_ids = [mid for mid, message in message_values.iteritems()
452 if message.get('author_id') and message.get('author_id') == partner_id]
453 # Create: Check messages you create that are private messages -> ir.rule ?
454 elif operation == 'create':
455 author_ids = [mid for mid, message in message_values.iteritems()
456 if not message.get('model') and not message.get('res_id')]
460 # Create: Check message_follower_ids
461 if operation == 'create':
462 doc_follower_ids = []
463 for model, mids in model_record_ids.items():
464 fol_obj = self.pool.get('mail.followers')
465 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [
466 ('res_model', '=', model),
467 ('res_id', 'in', list(mids)),
468 ('partner_id', '=', partner_id),
470 fol_mids = [follower.res_id for follower in fol_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context)]
471 doc_follower_ids += [mid for mid, message in message_values.iteritems()
472 if message.get('res_model') == model and message.get('res_id') in fol_mids]
474 doc_follower_ids = []
476 # Calculate remaining ids, and related model/res_ids
477 model_record_ids = {}
478 other_ids = set(ids).difference(set(notified_ids), set(author_ids), set(doc_follower_ids))
480 if message_values[id]['res_model']:
481 model_record_ids.setdefault(message_values[id]['res_model'], set()).add(message_values[id]['res_id'])
483 # CRUD: Access rights related to the document
484 document_related_ids = []
485 for model, mids in model_record_ids.items():
486 model_obj = self.pool.get(model)
487 mids = model_obj.exists(cr, uid, mids)
488 if operation in ['create', 'write', 'unlink']:
489 model_obj.check_access_rights(cr, uid, 'write')
490 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
492 model_obj.check_access_rights(cr, uid, operation)
493 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
494 document_related_ids += [mid for mid, message in message_values.iteritems()
495 if message.get('res_model') == model and message.get('res_id') in mids]
497 # Calculate remaining ids: if not void, raise an error
498 other_ids = set(ids).difference(set(notified_ids), set(author_ids), set(doc_follower_ids), set(document_related_ids))
501 raise orm.except_orm(_('Access Denied'),
502 _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % \
503 (self._description, operation))
505 def create(self, cr, uid, values, context=None):
506 if not values.get('message_id') and values.get('res_id') and values.get('model'):
507 values['message_id'] = tools.generate_tracking_message_id('%(res_id)s-%(model)s' % values)
508 newid = super(mail_message, self).create(cr, uid, values, context)
509 self._notify(cr, SUPERUSER_ID, newid, context=context)
512 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
513 """ Override to explicitely call check_access_rule, that is not called
514 by the ORM. It instead directly fetches ir.rules and apply them. """
515 self.check_access_rule(cr, uid, ids, 'read', context=context)
516 res = super(mail_message, self).read(cr, uid, ids, fields=fields, context=context, load=load)
519 def unlink(self, cr, uid, ids, context=None):
520 # cascade-delete attachments that are directly attached to the message (should only happen
521 # for mail.messages that act as parent for a standalone mail.mail record).
522 self.check_access_rule(cr, uid, ids, 'unlink', context=context)
523 attachments_to_delete = []
524 for message in self.browse(cr, uid, ids, context=context):
525 for attach in message.attachment_ids:
526 if attach.res_model == self._name and attach.res_id == message.id:
527 attachments_to_delete.append(attach.id)
528 if attachments_to_delete:
529 self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
530 return super(mail_message, self).unlink(cr, uid, ids, context=context)
532 def _notify_followers(self, cr, uid, newid, message, context=None):
533 """ Add the related record followers to the destination partner_ids.
535 partners_to_notify = set([])
536 # message has no subtype_id: pure log message -> no partners, no one notified
537 if not message.subtype_id:
538 message.write({'partner_ids': [5]})
540 # all partner_ids of the mail.message have to be notified
541 if message.partner_ids:
542 partners_to_notify |= set(partner.id for partner in message.partner_ids)
543 # all followers of the mail.message document have to be added as partners and notified
544 if message.model and message.res_id:
545 fol_obj = self.pool.get("mail.followers")
546 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)
547 fol_objs = fol_obj.browse(cr, uid, fol_ids, context=context)
548 extra_notified = set(fol.partner_id.id for fol in fol_objs)
549 missing_notified = extra_notified - partners_to_notify
551 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(4, p_id) for p_id in missing_notified]}, context=context)
553 def _notify(self, cr, uid, newid, context=None):
554 """ Add the related record followers to the destination partner_ids if is not a private message.
555 Call mail_notification.notify to manage the email sending
557 message = self.browse(cr, uid, newid, context=context)
558 if message.model and message.res_id:
559 self._notify_followers(cr, uid, newid, message, context=context)
561 # add myself if I wrote on my wall, otherwise remove myself author
562 if ((message.model == "res.partner" and message.res_id == message.author_id.id)):
563 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(4, message.author_id.id)]}, context=context)
565 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(3, message.author_id.id)]}, context=context)
567 self.pool.get('mail.notification')._notify(cr, uid, newid, context=context)
569 def copy(self, cr, uid, id, default=None, context=None):
570 """Overridden to avoid duplicating fields that are unique to each email"""
573 default.update(message_id=False, headers=False)
574 return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
576 #------------------------------------------------------
578 #------------------------------------------------------
580 def check_partners_email(self, cr, uid, partner_ids, context=None):
581 """ Verify that selected partner_ids have an email_address defined.
582 Otherwise throw a warning. """
583 partner_wo_email_lst = []
584 for partner in self.pool.get('res.partner').browse(cr, uid, partner_ids, context=context):
585 if not partner.email:
586 partner_wo_email_lst.append(partner)
587 if not partner_wo_email_lst:
589 for partner in partner_wo_email_lst:
590 recipients = "contacts"
591 if partner.customer and not partner.supplier:
592 recipients = "customers"
593 elif partner.supplier and not partner.customer:
594 recipients = "suppliers"
595 warning_msg = _('The following %s do not have an email address specified.') % (recipients,)
596 warning_msg += '\n- %s' % (partner.name)
598 'title': _('Email not found'),
599 'message': warning_msg,