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, fields
30 from osv.orm import except_orm
31 from tools.translate import _
33 _logger = logging.getLogger(__name__)
35 """ Some tools for parsing / creating email fields """
37 """Returns unicode() string conversion of the the given encoded smtp header text"""
39 text = decode_header(text.replace('\r', ''))
40 return ''.join([tools.ustr(x[0], x[1]) for x in text])
43 class mail_message(osv.Model):
44 """ Messages model: system notification (replacing res.log notifications),
45 comments (OpenChatter discussion) and incoming emails. """
46 _name = 'mail.message'
47 _description = 'Message'
48 _inherit = ['ir.needaction_mixin']
51 _message_read_limit = 10
52 _message_record_name_length = 18
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 get_name. """
61 result = dict.fromkeys(ids, '')
62 for message in self.browse(cr, uid, ids, context=context):
63 if not message.model or not message.res_id:
66 result[message.id] = self._shorten_name(self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1])
67 except openerp.exceptions.AccessDenied, e:
71 def _get_unread(self, cr, uid, ids, name, arg, context=None):
72 """ Compute if the message is unread by the current user. """
73 res = dict((id, {'unread': False}) for id in ids)
74 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
75 notif_obj = self.pool.get('mail.notification')
76 notif_ids = notif_obj.search(cr, uid, [
77 ('partner_id', 'in', [partner_id]),
78 ('message_id', 'in', ids),
81 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
82 res[notif.message_id.id]['unread'] = True
85 def _search_unread(self, cr, uid, obj, name, domain, context=None):
86 """ Search for messages unread by the current user. Condition is
87 inversed because we search unread message on a read column. """
89 read_cond = '(read = false or read is null)'
91 read_cond = 'read = true'
92 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
93 cr.execute("SELECT message_id FROM mail_notification "\
94 "WHERE partner_id = %%s AND %s" % read_cond,
96 return [('id', 'in', [r[0] for r in cr.fetchall()])]
98 def name_get(self, cr, uid, ids, context=None):
99 # name_get may receive int id instead of an id list
100 if isinstance(ids, (int, long)):
103 for message in self.browse(cr, uid, ids, context=context):
104 name = '%s: %s' % (message.subject or '', message.body or '')
105 res.append((message.id, self._shorten_name(name.lstrip(' :'))))
109 'type': fields.selection([
111 ('comment', 'Comment'),
112 ('notification', 'System notification'),
114 help="Message type: email for email message, notification for system "\
115 "message, comment for other messages such as user replies"),
116 'author_id': fields.many2one('res.partner', 'Author', required=True),
117 'partner_ids': fields.many2many('res.partner', 'mail_notification', 'message_id', 'partner_id', 'Recipients'),
118 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel',
119 'message_id', 'attachment_id', 'Attachments'),
120 'parent_id': fields.many2one('mail.message', 'Parent Message', select=True, ondelete='set null', help="Initial thread message."),
121 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
122 'model': fields.char('Related Document Model', size=128, select=1),
123 'res_id': fields.integer('Related Document ID', select=1),
124 'record_name': fields.function(_get_record_name, type='string',
125 string='Message Record Name',
126 help="Name get of the related document."),
127 'notification_ids': fields.one2many('mail.notification', 'message_id', 'Notifications'),
128 'subject': fields.char('Subject'),
129 'date': fields.datetime('Date'),
130 'message_id': fields.char('Message-Id', help='Message unique identifier', select=1, readonly=1),
131 'body': fields.html('Contents', help='Automatically sanitized HTML contents'),
132 'unread': fields.function(_get_unread, fnct_search=_search_unread,
133 type='boolean', string='Unread',
134 help='Functional field to search for unread messages linked to uid'),
137 def _needaction_domain_get(self, cr, uid, context=None):
139 return [('unread', '=', True)]
142 def _get_default_author(self, cr, uid, context=None):
143 # remove context to avoid possible hack in browse with superadmin using context keys that could trigger a specific behavior
144 return self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=None).partner_id.id
148 'date': lambda *a: fields.datetime.now(),
149 'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
153 #------------------------------------------------------
154 # Message loading for web interface
155 #------------------------------------------------------
157 def _message_dict_get(self, cr, uid, msg, context=None):
158 """ Return a dict representation of the message browse record. """
159 # TDE TEMP: use SUPERUSER_ID
160 # attachment_ids = [{'id': attach[0], 'name': attach[1]} for attach in self.pool.get('ir.attachment').name_get(cr, SUPERUSER_ID, [x.id for x in msg.attachment_ids], context=context)]
162 # author_id = self.pool.get('res.partner').name_get(cr, SUPERUSER_ID, [msg.author_id.id], context=context)[0]
164 # author_user_id = self.pool.get('res.users').name_get(cr, SUPERUSER_ID, [msg.author_id.user_ids[0].id], context=context)[0]
165 author_user_id = False
166 # partner_ids = self.pool.get('res.partner').name_get(cr, SUPERUSER_ID, [x.id for x in msg.partner_ids], context=context)
171 'attachment_ids': attachment_ids,
174 'res_id': msg.res_id,
175 'record_name': msg.record_name,
176 'subject': msg.subject,
178 'author_id': author_id,
179 'author_user_id': author_user_id,
180 'partner_ids': partner_ids,
184 def message_read_tree_flatten(self, cr, uid, messages, current_level, level, context=None):
185 """ Given a tree with several roots of following structure :
186 [ {'id': 1, 'child_ids': [
187 {'id': 11, 'child_ids': [...] },],
189 Flatten it to have a maximum number of levels, 0 being flat and
190 sort messages in a level according to a key of the messages.
191 Perform the flattening at leafs if above the maximum depth, then get
193 :param context: ``sort_key``: key for sorting (id by default)
194 :param context: ``sort_reverse``: reverser order for sorting (True by default)
196 def _flatten(msg_dict):
197 """ from {'id': x, 'child_ids': [{child1}, {child2}]}
198 get [{'id': x, 'child_ids': []}, {child1}, {child2}]
200 child_ids = msg_dict.pop('child_ids', [])
201 msg_dict['child_ids'] = []
202 return [msg_dict] + child_ids
203 # return sorted([msg_dict] + child_ids, key=itemgetter('id'), reverse=True)
204 context = context or {}
205 # Depth-first flattening
206 for message in messages:
207 if message.get('type') == 'expandable':
209 message['child_ids'] = self.message_read_tree_flatten(cr, uid, message['child_ids'], current_level + 1, level, context=context)
210 # Flatten if above maximum depth
211 if current_level < level:
212 return_list = messages
215 for message in messages:
216 for flat_message in _flatten(message):
217 return_list.append(flat_message)
218 return sorted(return_list, key=itemgetter(context.get('sort_key', 'id')), reverse=context.get('sort_reverse', True))
220 def message_read(self, cr, uid, ids=False, domain=[], thread_level=0, limit=None, context=None):
221 """ If IDs are provided, fetch these records. Otherwise use the domain
222 to fetch the matching records.
223 After having fetched the records provided by IDs, it will fetch the
224 parents to have well-formed threads.
225 :return list: list of trees of messages
227 limit = limit or self._message_read_limit
228 context = context or {}
230 ids = self.search(cr, SUPERUSER_ID, domain, context=context, limit=limit)
231 messages = self.browse(cr, uid, ids, context=context)
234 tree = {} # key: ID, value: record
236 if len(result) < (limit - 1):
237 record = self._message_dict_get(cr, uid, msg, context=context)
238 if thread_level and msg.parent_id:
240 if msg.parent_id.id in tree:
241 record_parent = tree[msg.parent_id.id]
243 record_parent = self._message_dict_get(cr, uid, msg.parent_id, context=context)
244 if msg.parent_id.parent_id:
245 tree[msg.parent_id.id] = record_parent
246 if record['id'] not in [x['id'] for x in record_parent['child_ids']]:
247 record_parent['child_ids'].append(record)
248 record = record_parent
250 if msg.id not in tree:
251 result.append(record)
252 tree[msg.id] = record
255 'type': 'expandable',
256 'domain': [('id', '<=', msg.id)] + domain,
258 'thread_level': thread_level, # should be improve accodting to level of records
265 result = self.message_read_tree_flatten(cr, uid, result, 0, thread_level, context=context)
268 #------------------------------------------------------
270 #------------------------------------------------------
273 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
274 if not cr.fetchone():
275 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
277 def check_access_rule(self, cr, uid, ids, operation, context=None):
278 """ Access rules of mail.message:
280 - notification exist (I receive pushed message) OR
281 - author_id = pid (I am the author) OR
282 - I can read the related document if res_model, res_id
285 - I am in the document message_follower_ids OR
286 - I can write on the related document if res_model, res_id
289 - I can write on the related document if res_model, res_id
292 - I can write on the related document if res_model, res_id
295 if uid == SUPERUSER_ID:
297 if isinstance(ids, (int, long)):
299 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=None)['partner_id'][0]
301 # Read mail_message.ids to have their values
302 model_record_ids = {}
303 message_values = dict.fromkeys(ids)
304 cr.execute('SELECT DISTINCT id, model, res_id, author_id FROM mail_message WHERE id = ANY (%s)', (ids,))
305 for id, rmod, rid, author_id in cr.fetchall():
306 message_values[id] = {'res_model': rmod, 'res_id': rid, 'author_id': author_id}
308 model_record_ids.setdefault(rmod, set()).add(rid)
310 # Read: Check for received notifications -> could become an ir.rule, but not till we do not have a many2one variable field
311 if operation == 'read':
312 not_obj = self.pool.get('mail.notification')
313 not_ids = not_obj.search(cr, SUPERUSER_ID, [
314 ('partner_id', '=', partner_id),
315 ('message_id', 'in', ids),
317 notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, SUPERUSER_ID, not_ids, context=context)]
320 # Read: Check messages you are author -> could become an ir.rule, but not till we do not have a many2one variable field
321 if operation == 'read':
322 author_ids = [mid for mid, message in message_values.iteritems()
323 if message.get('author_id') and message.get('author_id') == partner_id]
327 # Create: Check message_follower_ids
328 if operation == 'create':
329 doc_follower_ids = []
330 for model, mids in model_record_ids.items():
331 fol_obj = self.pool.get('mail.followers')
332 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [
333 ('res_model', '=', model),
334 ('res_id', 'in', list(mids)),
335 ('partner_id', '=', partner_id),
337 fol_mids = [follower.res_id for follower in fol_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context)]
338 doc_follower_ids += [mid for mid, message in message_values.iteritems()
339 if message.get('res_model') == model and message.get('res_id') in fol_mids]
341 doc_follower_ids = []
343 # Calculate remaining ids, and related model/res_ids
344 model_record_ids = {}
345 other_ids = set(ids).difference(set(notified_ids), set(author_ids), set(doc_follower_ids))
347 if message_values[id]['res_model']:
348 model_record_ids.setdefault(message_values[id]['res_model'], set()).add(message_values[id]['res_id'])
350 # CRUD: Access rights related to the document
351 document_related_ids = []
352 for model, mids in model_record_ids.items():
353 model_obj = self.pool.get(model)
354 mids = model_obj.exists(cr, uid, mids)
355 if operation in ['create', 'write', 'unlink']:
356 model_obj.check_access_rights(cr, uid, 'write')
357 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
359 model_obj.check_access_rights(cr, uid, operation)
360 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
361 document_related_ids += [mid for mid, message in message_values.iteritems()
362 if message.get('res_model') == model and message.get('res_id') in mids]
364 # Calculate remaining ids: if not void, raise an error
365 other_ids = set(ids).difference(set(notified_ids), set(author_ids), set(doc_follower_ids), set(document_related_ids))
368 raise except_orm(_('Access Denied'),
369 _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % \
370 (self._description, operation))
372 def create(self, cr, uid, values, context=None):
373 if not values.get('message_id') and values.get('res_id') and values.get('model'):
374 values['message_id'] = tools.generate_tracking_message_id('%(model)s-%(res_id)s' % values)
375 newid = super(mail_message, self).create(cr, uid, values, context)
376 self.notify(cr, uid, newid, context=context)
379 def read(self, cr, uid, ids, fields=None, context=None, load='_classic_read'):
380 """ Override to explicitely call check_access_rule, that is not called
381 by the ORM. It instead directly fetches ir.rules and apply them. """
382 res = super(mail_message, self).read(cr, uid, ids, fields=fields, context=context, load=load)
384 self.check_access_rule(cr, uid, ids, 'read', context=context)
387 def unlink(self, cr, uid, ids, context=None):
388 # cascade-delete attachments that are directly attached to the message (should only happen
389 # for mail.messages that act as parent for a standalone mail.mail record).
390 attachments_to_delete = []
391 for message in self.browse(cr, uid, ids, context=context):
392 for attach in message.attachment_ids:
393 if attach.res_model == self._name and attach.res_id == message.id:
394 attachments_to_delete.append(attach.id)
395 if attachments_to_delete:
396 self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
397 return super(mail_message, self).unlink(cr, uid, ids, context=context)
399 def notify(self, cr, uid, newid, context=None):
400 """ Add the related record followers to the destination partner_ids.
401 Call mail_notification.notify to manage the email sending
403 message = self.browse(cr, uid, newid, context=context)
404 partners_to_notify = set([])
405 # add all partner_ids of the message
406 if message.partner_ids:
407 partners_to_notify |= set(partner.id for partner in message.partner_ids)
408 # add all followers and set add them in partner_ids
409 if message.model and message.res_id:
410 record = self.pool.get(message.model).browse(cr, SUPERUSER_ID, message.res_id, context=context)
411 extra_notified = set(partner.id for partner in record.message_follower_ids)
412 missing_notified = extra_notified - partners_to_notify
414 self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(4, p_id) for p_id in missing_notified]}, context=context)
415 partners_to_notify |= extra_notified
416 self.pool.get('mail.notification').notify(cr, uid, list(partners_to_notify), newid, context=context)
418 def copy(self, cr, uid, id, default=None, context=None):
419 """Overridden to avoid duplicating fields that are unique to each email"""
422 default.update(message_id=False, headers=False)
423 return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
425 #------------------------------------------------------
427 #------------------------------------------------------
429 def check_partners_email(self, cr, uid, partner_ids, context=None):
430 """ Verify that selected partner_ids have an email_address defined.
431 Otherwise throw a warning. """
432 partner_wo_email_lst = []
433 for partner in self.pool.get('res.partner').browse(cr, uid, partner_ids, context=context):
434 if not partner.email:
435 partner_wo_email_lst.append(partner)
436 if not partner_wo_email_lst:
438 warning_msg = _('The following partners chosen as recipients for the email have no email address linked :')
439 for partner in partner_wo_email_lst:
440 warning_msg += '\n- %s' % (partner.name)
442 'title': _('Partners email addresses not found'),
443 'message': warning_msg,