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 operator import itemgetter
28 from osv import osv, fields
29 from tools.translate import _
31 _logger = logging.getLogger(__name__)
33 """ Some tools for parsing / creating email fields """
35 """Returns unicode() string conversion of the the given encoded smtp header text"""
37 text = decode_header(text.replace('\r', ''))
38 return ''.join([tools.ustr(x[0], x[1]) for x in text])
41 class mail_message(osv.Model):
42 """ Messages model: system notification (replacing res.log notifications),
43 comments (OpenChatter discussion) and incoming emails. """
44 _name = 'mail.message'
45 _description = 'Message'
46 _inherit = ['ir.needaction_mixin']
49 _message_read_limit = 10
50 _message_record_name_length = 18
52 def _shorten_name(self, name):
53 if len(name) <= (self._message_record_name_length + 3):
55 return name[:self._message_record_name_length] + '...'
57 def _get_record_name(self, cr, uid, ids, name, arg, context=None):
58 """ Return the related document name, using get_name. """
59 result = dict.fromkeys(ids, '')
60 for message in self.browse(cr, uid, ids, context=context):
61 if not message.model or not message.res_id:
64 result[message.id] = self._shorten_name(self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1])
65 except openerp.exceptions.AccessDenied, e:
69 def _get_unread(self, cr, uid, ids, name, arg, context=None):
70 """ Compute if the message is unread by the current user. """
71 res = dict((id, {'unread': False}) for id in ids)
72 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
73 notif_obj = self.pool.get('mail.notification')
74 notif_ids = notif_obj.search(cr, uid, [
75 ('partner_id', 'in', [partner_id]),
76 ('message_id', 'in', ids),
79 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
80 res[notif.message_id.id]['unread'] = True
83 def _search_unread(self, cr, uid, obj, name, domain, context=None):
84 """ Search for messages unread by the current user. Condition is
85 inversed because we search unread message on a read column. """
87 read_cond = '(read = false or read is null)'
89 read_cond = 'read = true'
90 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
91 cr.execute("SELECT message_id FROM mail_notification "\
92 "WHERE partner_id = %%s AND %s" % read_cond,
94 return [('id', 'in', [r[0] for r in cr.fetchall()])]
96 def name_get(self, cr, uid, ids, context=None):
97 # name_get may receive int id instead of an id list
98 if isinstance(ids, (int, long)):
101 for message in self.browse(cr, uid, ids, context=context):
102 name = '%s: %s' % (message.subject or '', message.body or '')
103 res.append((message.id, self._shorten_name(name.lstrip(' :'))))
107 'type': fields.selection([
109 ('comment', 'Comment'),
110 ('notification', 'System notification'),
112 help="Message type: email for email message, notification for system "\
113 "message, comment for other messages such as user replies"),
114 'author_id': fields.many2one('res.partner', 'Author', required=True),
115 'partner_ids': fields.many2many('res.partner', 'mail_notification', 'message_id', 'partner_id', 'Recipients'),
116 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel',
117 'message_id', 'attachment_id', 'Attachments'),
118 'parent_id': fields.many2one('mail.message', 'Parent Message', select=True, ondelete='set null', help="Initial thread message."),
119 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
120 'model': fields.char('Related Document Model', size=128, select=1),
121 'res_id': fields.integer('Related Document ID', select=1),
122 'record_name': fields.function(_get_record_name, type='string',
123 string='Message Record Name',
124 help="Name get of the related document."),
125 'notification_ids': fields.one2many('mail.notification', 'message_id', 'Notifications'),
126 'subject': fields.char('Subject'),
127 'date': fields.datetime('Date'),
128 'message_id': fields.char('Message-Id', help='Message unique identifier', select=1, readonly=1),
129 'body': fields.html('Contents', help='Automatically sanitized HTML contents'),
130 'unread': fields.function(_get_unread, fnct_search=_search_unread,
131 type='boolean', string='Unread',
132 help='Functional field to search for unread messages linked to uid'),
133 'vote_user_ids': fields.many2many('res.users', 'mail_vote', 'message_id', 'user_id', 'Votes'),
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 return self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
147 'date': lambda *a: fields.datetime.now(),
148 'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
152 #---------------------------------------------------
153 #Mail Vote system (Like or Unlike comments
154 #-----------------------------------------------------
155 def vote_toggle(self, cr, uid, ids, user_ids=None, context=None):
157 Toggles when Comment is liked or unlike.
158 create vote entries if current user like comment..
160 vote_pool = self.pool.get('mail.vote')
161 if not user_ids: user_ids = [uid]
162 for message in self.browse(cr, uid, ids, context):
163 voters_ids = [user.id for user in message.vote_user_ids if user.id == uid]
165 self.write(cr, uid, ids, {'vote_user_ids': [(4, user_id) for user_id in user_ids]}, context=context)
167 self.write(cr, uid, ids, {'vote_user_ids': [(3, user_id) for user_id in user_ids]}, context=context)
170 #------------------------------------------------------
171 # Message loading for web interface
172 #------------------------------------------------------
174 def _message_dict_get(self, cr, uid, msg, context=None):
175 """ Return a dict representation of the message browse record. """
176 vote_pool = self.pool.get('mail.vote')
179 vote_ids = vote_pool.name_get(cr, uid, [user.id for user in msg.vote_user_ids], context=context)
180 for user_id in msg.vote_user_ids:
181 if (user_id.id == uid):
183 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)]
184 author_id = self.pool.get('res.partner').name_get(cr, uid, [msg.author_id.id], context=context)[0]
185 author_user_id = self.pool.get('res.users').name_get(cr, uid, [msg.author_id.user_ids[0].id], context=context)[0]
186 partner_ids = self.pool.get('res.partner').name_get(cr, uid, [x.id for x in msg.partner_ids], context=context)
190 'attachment_ids': attachment_ids,
193 'res_id': msg.res_id,
194 'record_name': msg.record_name,
195 'subject': msg.subject,
197 'author_id': author_id,
198 'author_user_id': author_user_id,
199 'partner_ids': partner_ids,
201 'vote_user_ids': vote_ids,
202 'has_voted': has_voted
205 def message_read_tree_flatten(self, cr, uid, messages, current_level, level, context=None):
206 """ Given a tree with several roots of following structure :
207 [ {'id': 1, 'child_ids': [
208 {'id': 11, 'child_ids': [...] },],
210 Flatten it to have a maximum number of levels, 0 being flat and
211 sort messages in a level according to a key of the messages.
212 Perform the flattening at leafs if above the maximum depth, then get
214 :param context: ``sort_key``: key for sorting (id by default)
215 :param context: ``sort_reverse``: reverser order for sorting (True by default)
217 def _flatten(msg_dict):
218 """ from {'id': x, 'child_ids': [{child1}, {child2}]}
219 get [{'id': x, 'child_ids': []}, {child1}, {child2}]
221 child_ids = msg_dict.pop('child_ids', [])
222 msg_dict['child_ids'] = []
223 return [msg_dict] + child_ids
224 # return sorted([msg_dict] + child_ids, key=itemgetter('id'), reverse=True)
225 context = context or {}
226 # Depth-first flattening
227 for message in messages:
228 if message.get('type') == 'expandable':
230 message['child_ids'] = self.message_read_tree_flatten(cr, uid, message['child_ids'], current_level + 1, level, context=context)
231 # Flatten if above maximum depth
232 if current_level < level:
233 return_list = messages
236 for message in messages:
237 for flat_message in _flatten(message):
238 return_list.append(flat_message)
239 return sorted(return_list, key=itemgetter(context.get('sort_key', 'id')), reverse=context.get('sort_reverse', True))
241 def message_read(self, cr, uid, ids=False, domain=[], thread_level=0, limit=None, context=None):
242 """ If IDs are provided, fetch these records. Otherwise use the domain
243 to fetch the matching records.
244 After having fetched the records provided by IDs, it will fetch the
245 parents to have well-formed threads.
246 :return list: list of trees of messages
248 limit = limit or self._message_read_limit
249 context = context or {}
251 ids = self.search(cr, uid, domain, context=context, limit=limit)
252 messages = self.browse(cr, uid, ids, context=context)
255 tree = {} # key: ID, value: record
257 if len(result) < (limit - 1):
258 record = self._message_dict_get(cr, uid, msg, context=context)
259 if thread_level and msg.parent_id:
261 if msg.parent_id.id in tree:
262 record_parent = tree[msg.parent_id.id]
264 record_parent = self._message_dict_get(cr, uid, msg.parent_id, context=context)
265 if msg.parent_id.parent_id:
266 tree[msg.parent_id.id] = record_parent
267 if record['id'] not in [x['id'] for x in record_parent['child_ids']]:
268 record_parent['child_ids'].append(record)
269 record = record_parent
271 if msg.id not in tree:
272 result.append(record)
273 tree[msg.id] = record
276 'type': 'expandable',
277 'domain': [('id', '<=', msg.id)] + domain,
279 'thread_level': thread_level, # should be improve accodting to level of records
286 result = self.message_read_tree_flatten(cr, uid, result, 0, thread_level, context=context)
289 #------------------------------------------------------
291 #------------------------------------------------------
294 cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
295 if not cr.fetchone():
296 cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
298 def check_access_rule(self, cr, uid, ids, operation, context=None):
299 """ mail.message access rule check
300 - message received (a notification exists) -> ok
301 - check rules of related document if exists
302 - fallback on normal mail.message check """
303 if isinstance(ids, (int, long)):
306 # check messages for which you have a notification
307 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
308 not_obj = self.pool.get('mail.notification')
309 not_ids = not_obj.search(cr, uid, [
310 ('partner_id', '=', partner_id),
311 ('message_id', 'in', ids),
313 notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, uid, not_ids, context=context)
314 if notification.message_id.id in ids]
316 # check messages linked to an existing document
317 model_record_ids = {}
319 cr.execute('SELECT DISTINCT id, model, res_id FROM mail_message WHERE id = ANY (%s)', (ids,))
320 for id, rmod, rid in cr.fetchall():
321 if not (rmod and rid):
323 document_ids.append(id)
324 model_record_ids.setdefault(rmod, set()).add(rid)
325 for model, mids in model_record_ids.items():
326 model_obj = self.pool.get(model)
327 mids = model_obj.exists(cr, uid, mids)
328 model_obj.check_access_rights(cr, uid, operation)
329 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
331 # fall back on classic operation for other ids
332 other_ids = set(ids).difference(set(notified_ids), set(document_ids))
333 super(mail_message, self).check_access_rule(cr, uid, other_ids, operation, context=None)
335 def create(self, cr, uid, values, context=None):
336 if not values.get('message_id') and values.get('res_id') and values.get('model'):
337 values['message_id'] = tools.generate_tracking_message_id('%(model)s-%(res_id)s' % values)
338 newid = super(mail_message, self).create(cr, uid, values, context)
339 self.notify(cr, uid, newid, context=context)
342 def unlink(self, cr, uid, ids, context=None):
343 # cascade-delete attachments that are directly attached to the message (should only happen
344 # for mail.messages that act as parent for a standalone mail.mail record).
345 attachments_to_delete = []
346 for message in self.browse(cr, uid, ids, context=context):
347 for attach in message.attachment_ids:
348 if attach.res_model == self._name and attach.res_id == message.id:
349 attachments_to_delete.append(attach.id)
350 if attachments_to_delete:
351 self.pool.get('ir.attachment').unlink(cr, uid, attachments_to_delete, context=context)
352 return super(mail_message, self).unlink(cr, uid, ids, context=context)
354 def notify(self, cr, uid, newid, context=None):
355 """ Add the related record followers to the destination partner_ids.
356 Call mail_notification.notify to manage the email sending
358 message = self.browse(cr, uid, newid, context=context)
359 partners_to_notify = set([])
360 # add all partner_ids of the message
361 if message.partner_ids:
362 partners_to_notify |= set(partner.id for partner in message.partner_ids)
363 # add all followers and set add them in partner_ids
364 if message.model and message.res_id:
365 record = self.pool.get(message.model).browse(cr, uid, message.res_id, context=context)
366 extra_notified = set(partner.id for partner in record.message_follower_ids)
367 missing_notified = extra_notified - partners_to_notify
369 message.write({'partner_ids': [(4, p_id) for p_id in missing_notified]})
370 partners_to_notify |= extra_notified
371 self.pool.get('mail.notification').notify(cr, uid, list(partners_to_notify), newid, context=context)
373 def copy(self, cr, uid, id, default=None, context=None):
374 """Overridden to avoid duplicating fields that are unique to each email"""
377 default.update(message_id=False, headers=False)
378 return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
380 #------------------------------------------------------
382 #------------------------------------------------------
384 def check_partners_email(self, cr, uid, partner_ids, context=None):
385 """ Verify that selected partner_ids have an email_address defined.
386 Otherwise throw a warning. """
387 partner_wo_email_lst = []
388 for partner in self.pool.get('res.partner').browse(cr, uid, partner_ids, context=context):
389 if not partner.email:
390 partner_wo_email_lst.append(partner)
391 if not partner_wo_email_lst:
393 warning_msg = _('The following partners chosen as recipients for the email have no email address linked :')
394 for partner in partner_wo_email_lst:
395 warning_msg += '\n- %s' % (partner.name)
397 'title': _('Partners email addresses not found'),
398 'message': warning_msg,