61ea9fb9f6beac3a41c292b58011062cb1196c3c
[odoo/odoo.git] / addons / mail / mail_message.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2010-today OpenERP SA (<http://www.openerp.com>)
6 #
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
11 #
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
16 #
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/>
19 #
20 ##############################################################################
21
22 import logging
23 import openerp
24 import tools
25
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 _
31
32 _logger = logging.getLogger(__name__)
33
34 """ Some tools for parsing / creating email fields """
35 def decode(text):
36     """Returns unicode() string conversion of the the given encoded smtp header text"""
37     if text:
38         text = decode_header(text.replace('\r', ''))
39         return ''.join([tools.ustr(x[0], x[1]) for x in text])
40
41
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']
48     _order = 'id desc'
49
50     _message_read_limit = 10
51     _message_record_name_length = 18
52
53     def _shorten_name(self, name):
54         if len(name) <= (self._message_record_name_length + 3):
55             return name
56         return name[:self._message_record_name_length] + '...'
57
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:
63                 continue
64             try:
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):
67                 pass
68         return result
69
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),
78             ('read', '=', False)
79         ], context=context)
80         for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
81             res[notif.message_id.id]['unread'] = True
82         return res
83
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. """
87         if domain[0][2]:
88             read_cond = '(read = false or read is null)'
89         else:
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,
94                     (partner_id,))
95         return [('id', 'in', [r[0] for r in cr.fetchall()])]
96
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)):
100             ids = [ids]
101         res = []
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(' :'))))
105         return res
106
107     _columns = {
108         'type': fields.selection([
109                         ('email', 'Email'),
110                         ('comment', 'Comment'),
111                         ('notification', 'System notification'),
112                         ], 'Type',
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'),
137     }
138
139     def _needaction_domain_get(self, cr, uid, context=None):
140         if self._needaction:
141             return [('unread', '=', True)]
142         return []
143
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
147
148     _defaults = {
149         'type': 'email',
150         'date': lambda *a: fields.datetime.now(),
151         'author_id': lambda self, cr, uid, ctx={}: self._get_default_author(cr, uid, ctx),
152         'body': '',
153     }
154
155     #------------------------------------------------------
156     # Vote/Like
157     #------------------------------------------------------
158
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. '''
162         if not user_ids:
163             user_ids = [uid]
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')
167                 if not has_voted:
168                     self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(4, user_id)]}, context=context)
169                 else:
170                     self.write(cr, SUPERUSER_ID, message.get('id'), {'vote_user_ids': [(3, user_id)]}, context=context)
171         return not(has_voted) or False
172
173     #------------------------------------------------------
174     # Message loading for web interface
175     #------------------------------------------------------
176
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).
182         """
183         child_nbr = len(msg.child_ids)
184         has_voted = False
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:
187             if vote[0] == uid:
188                 has_voted = True
189                 break
190         try:
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):
193             attachment_ids = []
194         try:
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):
198             author_id = False
199             is_author = False
200         try:
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):
203             partner_ids = []
204
205         return {
206             'id': msg.id,
207             'type': msg.type,
208             'attachment_ids': attachment_ids,
209             'body': msg.body,
210             'model': msg.model,
211             'res_id': msg.res_id,
212             'record_name': msg.record_name,
213             'subject': msg.subject,
214             'date': msg.date,
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
222         }
223
224     def message_read_tree_get_expandable(self, cr, uid, parent_message, last_message, domain=[], current_level=0, level=0, context=None):
225         """ . """
226         base_domain = [('id', '<', last_message['id'])]
227         if parent_message and current_level < level:
228             base_domain += [('parent_id', '=', parent_message['id'])]
229         elif parent_message:
230             base_domain += [('id', 'child_of', parent_message['id']), ('id', '!=', parent_message['id'])]
231         if domain:
232             base_domain += domain
233         extension = {   'type': 'expandable',
234                         'domain': base_domain,
235                         'thread_level': current_level,
236                         'context': context,
237                         'id': -1,
238                         }
239         return extension
240
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': [...] },],
245                 {...}   ]
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
249             back in the tree.
250             :param context: ``sort_key``: key for sorting (id by default)
251             :param context: ``sort_reverse``: reverser order for sorting (True by default)
252         """
253         def _flatten(msg_dict):
254             """ from    {'id': x, 'child_ids': [{child1}, {child2}]}
255                 get     [{'id': x, 'child_ids': []}, {child1}, {child2}]
256             """
257             child_ids = msg_dict.pop('child_ids', [])
258             msg_dict['child_ids'] = []
259             return [msg_dict] + child_ids
260
261         context = context or {}
262         limit = limit or self._message_read_limit
263
264         # Depth-first flattening
265         for message in messages:
266             if message.get('type') == 'expandable':
267                 continue
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':
271                     continue
272                 message['child_nbr'] += child['child_nbr']
273         # Flatten if above maximum depth
274         if current_level < level:
275             return_list = messages
276         else:
277             return_list = [flat_message for message in messages for flat_message in _flatten(message)]
278
279         # Add expandable
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)
287         return return_list
288
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
294             well formed threads.
295
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
300                 further parents
301             :return list: list of trees of messages
302         """
303
304         message_loaded = context and context.get('message_loaded') or [0]
305
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] ];
309
310         limit = limit or self._message_read_limit
311         context = context or {}
312
313         tree = []
314         result = []
315         record = None
316
317         # select ids
318         if ids:
319             for msg in self.browse(cr, uid, ids, context=context):
320                 result.append(self._message_dict_get(cr, uid, msg, context=context))
321             return result
322
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)
329                 tree.append(msg.id)
330                 result.append(record)
331
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:
335                     msg = msg.parent_id
336                     tree.append(msg.id)
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)
341
342         result = sorted(result, key=lambda k: k['id'])
343
344
345         tree_not = []   
346         # expandable for not show message
347         for id_msg in tree:
348             # get all childs
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
351             id_min=None
352             id_max=None
353             nb=0
354             for not_loaded_id in not_loaded_ids:
355                 if not_loaded_id not in tree:
356                     nb+=1
357                     if id_min==None or id_min>not_loaded_id:
358                         id_min=not_loaded_id
359                     if id_max==None or id_max<not_loaded_id:
360                         id_max=not_loaded_id
361                     tree_not.append(not_loaded_id)
362                 else:
363                     if nb>0:
364                         result.append({
365                             'domain': [['id','>=',id_min],['id','<=',id_max],['parent_id','=',id_msg]],
366                             'nb_messages': nb,
367                             'type': 'expandable', 
368                             'parent_id': id_msg,
369                             'id':  id_min
370                         })
371                     nb=0
372             if nb>0:
373                 result.append({
374                     'domain': [['id','>=',id_min],['parent_id','=',id_msg]],
375                     'nb_messages': nb,
376                     'type': 'expandable', 
377                     'parent_id': id_msg, 
378                     'id':  id_min
379                 })
380
381
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)
384         if len(ids) > 0:
385             result.append(
386             {
387                 'domain': domain,
388                 'nb_messages': 0,
389                 'type': 'expandable', 
390                 'parent_id': parent_id, 
391                 'id': -1
392             });
393
394
395         result = sorted(result, key=lambda k: k['id'])
396
397         return result
398
399     #------------------------------------------------------
400     # Email api
401     #------------------------------------------------------
402
403     def init(self, cr):
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)""")
407
408     def check_access_rule(self, cr, uid, ids, operation, context=None):
409         """ Access rules of mail.message:
410             - read: if
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
414                 - Otherwise: raise
415             - create: if
416                 - I am in the document message_follower_ids OR
417                 - I can write on the related document if res_model, res_id
418                 - Otherwise: raise
419             - write: if
420                 - I can write on the related document if res_model, res_id
421                 - Otherwise: raise
422             - unlink: if
423                 - I can write on the related document if res_model, res_id
424                 - Otherwise: raise
425         """
426         if uid == SUPERUSER_ID:
427             return
428         if isinstance(ids, (int, long)):
429             ids = [ids]
430         partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=None)['partner_id'][0]
431
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}
438             if rmod:
439                 model_record_ids.setdefault(rmod, set()).add(rid)
440
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),
447             ], context=context)
448             notified_ids = [notification.message_id.id for notification in not_obj.browse(cr, SUPERUSER_ID, not_ids, context=context)]
449         else:
450             notified_ids = []
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]
455         else:
456             author_ids = []
457
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),
467                     ], context=context)
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]
471         else:
472             doc_follower_ids = []
473
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))
477         for id in other_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'])
480
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)
489             else:
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]
494
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))
497         if not other_ids:
498             return
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))
502
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)
508         return newid
509
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)
515         return res
516
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)
528
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
532         """
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]})
538             return True
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
550             if 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
553
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)
558         else:
559             self.write(cr, SUPERUSER_ID, [newid], {'partner_ids': [(3, message.author_id.id)]}, context=context)
560
561         self.pool.get('mail.notification')._notify(cr, uid, newid, context=context)
562
563     def copy(self, cr, uid, id, default=None, context=None):
564         """Overridden to avoid duplicating fields that are unique to each email"""
565         if default is None:
566             default = {}
567         default.update(message_id=False, headers=False)
568         return super(mail_message, self).copy(cr, uid, id, default=default, context=context)
569
570     #------------------------------------------------------
571     # Tools
572     #------------------------------------------------------
573
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:
582             return {}
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)
586         return {'warning': {
587                     'title': _('Email not found'),
588                     'message': warning_msg,
589                     }
590                 }