[IMP] mail: models inherting from mail.thread can now flag their fields as 'tracked...
[odoo/odoo.git] / addons / mail / mail_thread.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2009-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 base64
23 from collections import defaultdict
24 import dateutil
25 import email
26 from functools import partial
27 import logging
28 import pytz
29 import time
30 import tools
31 import xmlrpclib
32
33 from mako.template import Template as MakoTemplate
34
35 from email.message import Message
36 from mail_message import decode
37 from openerp import SUPERUSER_ID
38 from osv import osv, fields
39 from openerp.osv.orm import browse_record
40 from tools.safe_eval import safe_eval as eval
41 from tools.translate import _
42
43 _logger = logging.getLogger(__name__)
44
45
46 def decode_header(message, header, separator=' '):
47     return separator.join(map(decode, message.get_all(header, [])))
48
49
50 class mail_thread(osv.AbstractModel):
51     ''' mail_thread model is meant to be inherited by any model that needs to
52         act as a discussion topic on which messages can be attached. Public
53         methods are prefixed with ``message_`` in order to avoid name
54         collisions with methods of the models that will inherit from this class.
55
56         ``mail.thread`` defines fields used to handle and display the
57         communication history. ``mail.thread`` also manages followers of
58         inheriting classes. All features and expected behavior are managed
59         by mail.thread. Widgets has been designed for the 7.0 and following
60         versions of OpenERP.
61
62         Inheriting classes are not required to implement any method, as the
63         default implementation will work for any model. However it is common
64         to override at least the ``message_new`` and ``message_update``
65         methods (calling ``super``) to add model-specific behavior at
66         creation and update of a thread when processing incoming emails.
67
68         Options:
69             - _mail_flat_thread: if set to True, all messages without parent_id
70                 are automatically attached to the first message posted on the
71                 ressource. If set to False, the display of Chatter is done using
72                 threads, and no parent_id is automatically set.
73     '''
74     _name = 'mail.thread'
75     _description = 'Email Thread'
76     _mail_flat_thread = True
77     _TRACK_TEMPLATE = """
78         <span>${updated_fields}</span>
79         <ul>
80         %for chg in changes:
81             <li><span>${chg[0]}</span>: ${chg[1]} -> ${chg[2]}</li>
82         %endfor
83         </ul>
84     """
85
86     def _get_message_data(self, cr, uid, ids, name, args, context=None):
87         """ Computes:
88             - message_unread: has uid unread message for the document
89             - message_summary: html snippet summarizing the Chatter for kanban views """
90         res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
91         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
92
93         # search for unread messages, directly in SQL to improve performances
94         cr.execute("""  SELECT m.res_id FROM mail_message m
95                         RIGHT JOIN mail_notification n
96                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
97                         WHERE m.model = %s AND m.res_id in %s""",
98                     (user_pid, self._name, tuple(ids),))
99         msg_ids = [result[0] for result in cr.fetchall()]
100         for msg_id in msg_ids:
101             res[msg_id]['message_unread'] = True
102
103         for thread in self.browse(cr, uid, ids, context=context):
104             cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
105             res[thread.id]['message_summary'] = "<span%s><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (cls, len(thread.message_comment_ids), len(thread.message_follower_ids))
106
107         return res
108
109     def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
110         """ Computes:
111             - message_subtype_data: data about document subtypes: which are
112                 available, which are followed if any """
113         res = dict((id, dict(message_subtype_data='')) for id in ids)
114         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
115
116         # find current model subtypes, add them to a dictionary
117         subtype_obj = self.pool.get('mail.message.subtype')
118         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
119         subtype_dict = dict((subtype.name, dict(default=subtype.default, followed=False, id=subtype.id)) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
120         for id in ids:
121             res[id]['message_subtype_data'] = subtype_dict.copy()
122
123         # find the document followers, update the data
124         fol_obj = self.pool.get('mail.followers')
125         fol_ids = fol_obj.search(cr, uid, [
126             ('partner_id', '=', user_pid),
127             ('res_id', 'in', ids),
128             ('res_model', '=', self._name),
129         ], context=context)
130         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
131             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
132             for subtype in fol.subtype_ids:
133                 thread_subtype_dict[subtype.name]['followed'] = True
134             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
135
136         return res
137
138     def _search_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
139         partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
140         res = {}
141         notif_obj = self.pool.get('mail.notification')
142         notif_ids = notif_obj.search(cr, uid, [
143             ('partner_id', '=', partner_id),
144             ('message_id.model', '=', self._name),
145             ('read', '=', False)
146         ], context=context)
147         for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
148             res[notif.message_id.res_id] = True
149         return [('id', 'in', res.keys())]
150
151     def _get_followers(self, cr, uid, ids, name, arg, context=None):
152         fol_obj = self.pool.get('mail.followers')
153         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
154         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
155         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
156         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
157             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
158             if fol.partner_id.id == user_pid:
159                 res[fol.res_id]['message_is_follower'] = True
160         return res
161
162     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
163         if not value:
164             return
165         partner_obj = self.pool.get('res.partner')
166         fol_obj = self.pool.get('mail.followers')
167
168         # read the old set of followers, and determine the new set of followers
169         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
170         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
171         new = set(old)
172
173         for command in value or []:
174             if isinstance(command, (int, long)):
175                 new.add(command)
176             elif command[0] == 0:
177                 new.add(partner_obj.create(cr, uid, command[2], context=context))
178             elif command[0] == 1:
179                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
180                 new.add(command[1])
181             elif command[0] == 2:
182                 partner_obj.unlink(cr, uid, [command[1]], context=context)
183                 new.discard(command[1])
184             elif command[0] == 3:
185                 new.discard(command[1])
186             elif command[0] == 4:
187                 new.add(command[1])
188             elif command[0] == 5:
189                 new.clear()
190             elif command[0] == 6:
191                 new = set(command[2])
192
193         # remove partners that are no longer followers
194         fol_ids = fol_obj.search(cr, SUPERUSER_ID,
195             [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
196         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
197
198         # add new followers
199         for partner_id in new - old:
200             fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
201
202     def _search_followers(self, cr, uid, obj, name, args, context):
203         fol_obj = self.pool.get('mail.followers')
204         res = []
205         for field, operator, value in args:
206             assert field == name
207             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
208             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
209             res.append(('id', 'in', res_ids))
210         return res
211
212     _columns = {
213         'message_is_follower': fields.function(_get_followers,
214             type='boolean', string='Is a Follower', multi='_get_followers,'),
215         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
216                 fnct_search=_search_followers, type='many2many',
217                 obj='res.partner', string='Followers', multi='_get_followers'),
218         'message_comment_ids': fields.one2many('mail.message', 'res_id',
219             domain=lambda self: [('model', '=', self._name), ('type', 'in', ('comment', 'email'))],
220             string='Comments and emails',
221             help="Comments and emails"),
222         'message_ids': fields.one2many('mail.message', 'res_id',
223             domain=lambda self: [('model', '=', self._name)],
224             string='Messages',
225             help="Messages and communication history"),
226         'message_unread': fields.function(_get_message_data, fnct_search=_search_unread,
227             type='boolean', string='Unread Messages', multi="_get_message_data",
228             help="If checked new messages require your attention."),
229         'message_summary': fields.function(_get_message_data, method=True,
230             type='text', string='Summary', multi="_get_message_data",
231             help="Holds the Chatter summary (number of messages, ...). "\
232                  "This summary is directly in html format in order to "\
233                  "be inserted in kanban views."),
234     }
235
236     #------------------------------------------------------
237     # Automatic subscription when creating
238     #------------------------------------------------------
239
240     def create(self, cr, uid, vals, context=None):
241         """ Override to subscribe the current user. """
242         thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
243         self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
244         return thread_id
245
246     def unlink(self, cr, uid, ids, context=None):
247         """ Override unlink to delete messages and followers. This cannot be
248             cascaded, because link is done through (res_model, res_id). """
249         msg_obj = self.pool.get('mail.message')
250         fol_obj = self.pool.get('mail.followers')
251         # delete messages and notifications
252         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
253         msg_obj.unlink(cr, uid, msg_ids, context=context)
254         # delete followers
255         fol_ids = fol_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
256         fol_obj.unlink(cr, uid, fol_ids, context=context)
257         return super(mail_thread, self).unlink(cr, uid, ids, context=context)
258
259     def copy(self, cr, uid, id, default=None, context=None):
260         default = default or {}
261         default['message_ids'] = []
262         default['message_comment_ids'] = []
263         default['message_follower_ids'] = []
264         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
265
266     #------------------------------------------------------
267     # Automatically log tracked fields
268     #------------------------------------------------------
269
270     def write(self, cr, uid, ids, values, context=None):
271         # XXX is translation a good idea?
272         # TODO get user lang if not in context
273         # XXX idea: store a datastructure and render it on demand in the correct language
274         # TODO? use link to record for m2o
275         # TODO handle x2m fields. how?
276
277         if context is None:
278             context = {}
279         #import pudb;pudb.set_trace()
280
281         def false_value(f):
282             if f._type == 'boolean':
283                 return False
284             return f._symbol_set[1](False)
285
286         def convert_for_comparison(v, f):
287             if not v:
288                 return false_value(f)
289             if isinstance(v, browse_record):
290                 return v.id
291             return v
292
293         def convert_for_display(v, f):
294             if not v:
295                 return false_value(f)
296             if f._type == 'many2one':
297                 if not isinstance(v, browse_record):
298                     v = self.pool[f.relation].browse(cr, SUPERUSER_ID, v)
299                 return v.name_get()[0][1]
300             if f._type == 'selection':
301                 # TODO get translated value
302                 pass
303             return v
304
305         tracked = dict((n, f) for n, f in self._all_columns.items() if getattr(f.column, 'tracked', False))
306         to_log = [k for k in values if k in tracked]
307
308         changes = defaultdict(list)
309         if to_log:
310             for record in self.browse(cr, uid, ids, context):
311                 for tl in to_log:
312                     column = tracked[tl].column
313                     current = convert_for_comparison(record[tl], column)
314                     new = convert_for_comparison(values[tl], column)
315                     if new != current:
316                         changes[record].append(tl)
317
318         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
319
320         updated_fields = _('Updated Fields:')
321
322         Trans = self.pool['ir.translation']
323         def _t(c):
324             model = c.parent_model or self._name
325             lang = context.get('lang')
326             return Trans._get_source(cr, uid, '{0},{1}'.format(model, c.name), 'field', lang, ci.column.string)
327
328         for record, changed_fields in changes.items():
329             # TODO tpl changed_fields
330             chg = []
331             for f in changed_fields:
332                 ci = tracked[f]
333                 from_ = convert_for_display(record[f], ci.column)
334                 to = convert_for_display(values[f], ci.column)
335                 chg.append((_t(ci), from_, to))
336
337             message = MakoTemplate(self._TRACK_TEMPLATE).render_unicode(updated_fields=updated_fields,
338                                                                         changes=chg)
339
340             record.message_post(message)
341
342         return result
343
344     #------------------------------------------------------
345     # mail.message wrappers and tools
346     #------------------------------------------------------
347
348     def _needaction_domain_get(self, cr, uid, context=None):
349         if self._needaction:
350             return [('message_unread', '=', True)]
351         return []
352
353     #------------------------------------------------------
354     # Mail gateway
355     #------------------------------------------------------
356
357     def message_capable_models(self, cr, uid, context=None):
358         """ Used by the plugin addon, based for plugin_outlook and others. """
359         ret_dict = {}
360         for model_name in self.pool.obj_list():
361             model = self.pool.get(model_name)
362             if 'mail.thread' in getattr(model, '_inherit', []):
363                 ret_dict[model_name] = model._description
364         return ret_dict
365
366     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
367         """ Find partners related to some header fields of the message. """
368         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
369         return [partner_id for email in tools.email_split(s)
370                 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
371
372     def _message_find_user_id(self, cr, uid, message, context=None):
373         from_local_part = tools.email_split(decode(message.get('From')))[0]
374         # FP Note: canonification required, the minimu: .lower()
375         user_ids = self.pool.get('res.users').search(cr, uid, ['|',
376             ('login', '=', from_local_part),
377             ('email', '=', from_local_part)], context=context)
378         return user_ids[0] if user_ids else uid
379
380     def message_route(self, cr, uid, message, model=None, thread_id=None,
381                       custom_values=None, context=None):
382         """Attempt to figure out the correct target model, thread_id,
383         custom_values and user_id to use for an incoming message.
384         Multiple values may be returned, if a message had multiple
385         recipients matching existing mail.aliases, for example.
386
387         The following heuristics are used, in this order:
388              1. If the message replies to an existing thread_id, and
389                 properly contains the thread model in the 'In-Reply-To'
390                 header, use this model/thread_id pair, and ignore
391                 custom_value (not needed as no creation will take place)
392              2. Look for a mail.alias entry matching the message
393                 recipient, and use the corresponding model, thread_id,
394                 custom_values and user_id.
395              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
396                 provided.
397              4. If all the above fails, raise an exception.
398
399            :param string message: an email.message instance
400            :param string model: the fallback model to use if the message
401                does not match any of the currently configured mail aliases
402                (may be None if a matching alias is supposed to be present)
403            :type dict custom_values: optional dictionary of default field values
404                 to pass to ``message_new`` if a new record needs to be created.
405                 Ignored if the thread record already exists, and also if a
406                 matching mail.alias was found (aliases define their own defaults)
407            :param int thread_id: optional ID of the record/thread from ``model``
408                to which this mail should be attached. Only used if the message
409                does not reply to an existing thread and does not match any mail alias.
410            :return: list of [model, thread_id, custom_values, user_id]
411         """
412         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
413         message_id = message.get('Message-Id')
414         references = decode_header(message, 'References')
415         in_reply_to = decode_header(message, 'In-Reply-To')
416
417         # 1. Verify if this is a reply to an existing thread
418         thread_references = references or in_reply_to
419         ref_match = thread_references and tools.reference_re.search(thread_references)
420         if ref_match:
421             thread_id = int(ref_match.group(1))
422             model = ref_match.group(2) or model
423             model_pool = self.pool.get(model)
424             if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
425                 and hasattr(model_pool, 'message_update'):
426                 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
427                               message_id, model, thread_id, custom_values, uid)
428                 return [(model, thread_id, custom_values, uid)]
429
430         # Verify this is a reply to a private message
431         message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
432         if message_ids:
433             message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
434             _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
435                             message_id, message.id, custom_values, uid)
436             return [(message.model, message.res_id, custom_values, uid)]
437
438         # 2. Look for a matching mail.alias entry
439         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
440         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
441         rcpt_tos = decode_header(message, 'Delivered-To') or \
442              ','.join([decode_header(message, 'To'),
443                        decode_header(message, 'Cc'),
444                        decode_header(message, 'Resent-To'),
445                        decode_header(message, 'Resent-Cc')])
446         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
447         if local_parts:
448             mail_alias = self.pool.get('mail.alias')
449             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
450             if alias_ids:
451                 routes = []
452                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
453                     user_id = alias.alias_user_id.id
454                     if not user_id:
455                         user_id = self._message_find_user_id(cr, uid, message, context=context)
456                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
457                                    eval(alias.alias_defaults), user_id))
458                 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
459                 return routes
460
461         # 3. Fallback to the provided parameters, if they work
462         model_pool = self.pool.get(model)
463         if not thread_id:
464             # Legacy: fallback to matching [ID] in the Subject
465             match = tools.res_re.search(decode_header(message, 'Subject'))
466             thread_id = match and match.group(1)
467         assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
468             "No possible route found for incoming message with Message-Id %s. " \
469             "Create an appropriate mail.alias or force the destination model." % message_id
470         if thread_id and not model_pool.exists(cr, uid, thread_id):
471             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
472                             thread_id, message_id)
473             thread_id = None
474         _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
475                       message_id, model, thread_id, custom_values, uid)
476         return [(model, thread_id, custom_values, uid)]
477
478     def message_process(self, cr, uid, model, message, custom_values=None,
479                         save_original=False, strip_attachments=False,
480                         thread_id=None, context=None):
481         """ Process an incoming RFC2822 email message, relying on
482             ``mail.message.parse()`` for the parsing operation,
483             and ``message_route()`` to figure out the target model.
484
485             Once the target model is known, its ``message_new`` method
486             is called with the new message (if the thread record did not exist)
487             or its ``message_update`` method (if it did).
488
489             There is a special case where the target model is False: a reply
490             to a private message. In this case, we skip the message_new /
491             message_update step, to just post a new message using mail_thread
492             message_post.
493
494            :param string model: the fallback model to use if the message
495                does not match any of the currently configured mail aliases
496                (may be None if a matching alias is supposed to be present)
497            :param message: source of the RFC2822 message
498            :type message: string or xmlrpclib.Binary
499            :type dict custom_values: optional dictionary of field values
500                 to pass to ``message_new`` if a new record needs to be created.
501                 Ignored if the thread record already exists, and also if a
502                 matching mail.alias was found (aliases define their own defaults)
503            :param bool save_original: whether to keep a copy of the original
504                 email source attached to the message after it is imported.
505            :param bool strip_attachments: whether to strip all attachments
506                 before processing the message, in order to save some space.
507            :param int thread_id: optional ID of the record/thread from ``model``
508                to which this mail should be attached. When provided, this
509                overrides the automatic detection based on the message
510                headers.
511         """
512         if context is None:
513             context = {}
514
515         # extract message bytes - we are forced to pass the message as binary because
516         # we don't know its encoding until we parse its headers and hence can't
517         # convert it to utf-8 for transport between the mailgate script and here.
518         if isinstance(message, xmlrpclib.Binary):
519             message = str(message.data)
520         # Warning: message_from_string doesn't always work correctly on unicode,
521         # we must use utf-8 strings here :-(
522         if isinstance(message, unicode):
523             message = message.encode('utf-8')
524         msg_txt = email.message_from_string(message)
525         routes = self.message_route(cr, uid, msg_txt, model,
526                                     thread_id, custom_values,
527                                     context=context)
528         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
529         if strip_attachments:
530             msg.pop('attachments', None)
531         thread_id = False
532         for model, thread_id, custom_values, user_id in routes:
533             if self._name != model:
534                 context.update({'thread_model': model})
535             if model:
536                 model_pool = self.pool.get(model)
537                 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
538                     "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
539                         (msg['message_id'], model)
540                 if thread_id and hasattr(model_pool, 'message_update'):
541                     model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
542                 else:
543                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
544             else:
545                 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
546                 model_pool = self.pool.get('mail.thread')
547             model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
548         return thread_id
549
550     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
551         """Called by ``message_process`` when a new message is received
552            for a given thread model, if the message did not belong to
553            an existing thread.
554            The default behavior is to create a new record of the corresponding
555            model (based on some very basic info extracted from the message).
556            Additional behavior may be implemented by overriding this method.
557
558            :param dict msg_dict: a map containing the email details and
559                                  attachments. See ``message_process`` and
560                                 ``mail.message.parse`` for details.
561            :param dict custom_values: optional dictionary of additional
562                                       field values to pass to create()
563                                       when creating the new thread record.
564                                       Be careful, these values may override
565                                       any other values coming from the message.
566            :param dict context: if a ``thread_model`` value is present
567                                 in the context, its value will be used
568                                 to determine the model of the record
569                                 to create (instead of the current model).
570            :rtype: int
571            :return: the id of the newly created thread object
572         """
573         if context is None:
574             context = {}
575         model = context.get('thread_model') or self._name
576         model_pool = self.pool.get(model)
577         fields = model_pool.fields_get(cr, uid, context=context)
578         data = model_pool.default_get(cr, uid, fields, context=context)
579         if 'name' in fields and not data.get('name'):
580             data['name'] = msg_dict.get('subject', '')
581         if custom_values and isinstance(custom_values, dict):
582             data.update(custom_values)
583         res_id = model_pool.create(cr, uid, data, context=context)
584         return res_id
585
586     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
587         """Called by ``message_process`` when a new message is received
588            for an existing thread. The default behavior is to update the record
589            with update_vals taken from the incoming email.
590            Additional behavior may be implemented by overriding this
591            method.
592            :param dict msg_dict: a map containing the email details and
593                                attachments. See ``message_process`` and
594                                ``mail.message.parse()`` for details.
595            :param dict update_vals: a dict containing values to update records
596                               given their ids; if the dict is None or is
597                               void, no write operation is performed.
598         """
599         if update_vals:
600             self.write(cr, uid, ids, update_vals, context=context)
601         return True
602
603     def _message_extract_payload(self, message, save_original=False):
604         """Extract body as HTML and attachments from the mail message"""
605         attachments = []
606         body = u''
607         if save_original:
608             attachments.append(('original_email.eml', message.as_string()))
609         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
610             encoding = message.get_content_charset()
611             body = message.get_payload(decode=True)
612             body = tools.ustr(body, encoding, errors='replace')
613             if message.get_content_type() == 'text/plain':
614                 # text/plain -> <pre/>
615                 body = tools.append_content_to_html(u'', body, preserve=True)
616         else:
617             alternative = (message.get_content_type() == 'multipart/alternative')
618             for part in message.walk():
619                 if part.get_content_maintype() == 'multipart':
620                     continue # skip container
621                 filename = part.get_filename() # None if normal part
622                 encoding = part.get_content_charset() # None if attachment
623                 # 1) Explicit Attachments -> attachments
624                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
625                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
626                     continue
627                 # 2) text/plain -> <pre/>
628                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
629                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
630                                                                          encoding, errors='replace'), preserve=True)
631                 # 3) text/html -> raw
632                 elif part.get_content_type() == 'text/html':
633                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
634                     if alternative:
635                         body = html
636                     else:
637                         body = tools.append_content_to_html(body, html, plaintext=False)
638                 # 4) Anything else -> attachment
639                 else:
640                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
641         return body, attachments
642
643     def message_parse(self, cr, uid, message, save_original=False, context=None):
644         """Parses a string or email.message.Message representing an
645            RFC-2822 email, and returns a generic dict holding the
646            message details.
647
648            :param message: the message to parse
649            :type message: email.message.Message | string | unicode
650            :param bool save_original: whether the returned dict
651                should include an ``original`` attachment containing
652                the source of the message
653            :rtype: dict
654            :return: A dict with the following structure, where each
655                     field may not be present if missing in original
656                     message::
657
658                     { 'message_id': msg_id,
659                       'subject': subject,
660                       'from': from,
661                       'to': to,
662                       'cc': cc,
663                       'body': unified_body,
664                       'attachments': [('file1', 'bytes'),
665                                       ('file2', 'bytes')}
666                     }
667         """
668         msg_dict = {
669             'type': 'email',
670             'author_id': False,
671         }
672         if not isinstance(message, Message):
673             if isinstance(message, unicode):
674                 # Warning: message_from_string doesn't always work correctly on unicode,
675                 # we must use utf-8 strings here :-(
676                 message = message.encode('utf-8')
677             message = email.message_from_string(message)
678
679         message_id = message['message-id']
680         if not message_id:
681             # Very unusual situation, be we should be fault-tolerant here
682             message_id = "<%s@localhost>" % time.time()
683             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
684         msg_dict['message_id'] = message_id
685
686         if 'Subject' in message:
687             msg_dict['subject'] = decode(message.get('Subject'))
688
689         # Envelope fields not stored in mail.message but made available for message_new()
690         msg_dict['from'] = decode(message.get('from'))
691         msg_dict['to'] = decode(message.get('to'))
692         msg_dict['cc'] = decode(message.get('cc'))
693
694         if 'From' in message:
695             author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
696             if author_ids:
697                 msg_dict['author_id'] = author_ids[0]
698             else:
699                 msg_dict['email_from'] = message.get('from')
700         partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
701         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
702
703         if 'Date' in message:
704             date_hdr = decode(message.get('Date'))
705             # convert from email timezone to server timezone
706             date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
707             date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
708             msg_dict['date'] = date_server_datetime_str
709
710         if 'In-Reply-To' in message:
711             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
712             if parent_ids:
713                 msg_dict['parent_id'] = parent_ids[0]
714
715         if 'References' in message and 'parent_id' not in msg_dict:
716             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
717                                                                          [x.strip() for x in decode(message['References']).split()])])
718             if parent_ids:
719                 msg_dict['parent_id'] = parent_ids[0]
720
721         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
722         return msg_dict
723
724     #------------------------------------------------------
725     # Note specific
726     #------------------------------------------------------
727
728     def log(self, cr, uid, id, message, secondary=False, context=None):
729         _logger.warning("log() is deprecated. As this module inherit from "\
730                         "mail.thread, the message will be managed by this "\
731                         "module instead of by the res.log mechanism. Please "\
732                         "use mail_thread.message_post() instead of the "\
733                         "now deprecated res.log.")
734         self.message_post(cr, uid, [id], message, context=context)
735
736     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
737                         subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
738         """ Post a new message in an existing thread, returning the new
739             mail.message ID. Extra keyword arguments will be used as default
740             column values for the new mail.message record.
741             Auto link messages for same id and object
742             :param int thread_id: thread ID to post into, or list with one ID;
743                 if False/0, mail.message model will also be set as False
744             :param str body: body of the message, usually raw HTML that will
745                 be sanitized
746             :param str subject: optional subject
747             :param str type: mail_message.type
748             :param int parent_id: optional ID of parent message in this thread
749             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
750                 ``(name,content)``, where content is NOT base64 encoded
751             :return: ID of newly created mail.message
752         """
753         if context is None:
754             context = {}
755         if attachments is None:
756             attachments = {}
757
758         assert (not thread_id) or isinstance(thread_id, (int, long)) or \
759             (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), "Invalid thread_id; should be 0, False, an ID or a list with one ID"
760         if isinstance(thread_id, (list, tuple)):
761             thread_id = thread_id and thread_id[0]
762         mail_message = self.pool.get('mail.message')
763         model = context.get('thread_model', self._name) if thread_id else False
764
765         attachment_ids = []
766         for name, content in attachments:
767             if isinstance(content, unicode):
768                 content = content.encode('utf-8')
769             data_attach = {
770                 'name': name,
771                 'datas': base64.b64encode(str(content)),
772                 'datas_fname': name,
773                 'description': name,
774                 'res_model': context.get('thread_model') or self._name,
775                 'res_id': thread_id,
776             }
777             attachment_ids.append((0, 0, data_attach))
778
779         # fetch subtype
780         if subtype:
781             s_data = subtype.split('.')
782             if len(s_data) == 1:
783                 s_data = ('mail', s_data[0])
784             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
785             subtype_id = ref and ref[1] or False
786         else:
787             subtype_id = False
788
789         # _mail_flat_thread: automatically set free messages to the first posted message
790         if self._mail_flat_thread and not parent_id and thread_id:
791             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
792             parent_id = message_ids and message_ids[0] or False
793         # we want to set a parent: force to set the parent_id to the oldest ancestor, to avoid having more than 1 level of thread
794         elif parent_id:
795             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
796             # avoid loops when finding ancestors
797             processed_list = []
798             if message_ids:
799                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
800                 while (message.parent_id and message.parent_id.id not in processed_list):
801                     processed_list.append(message.parent_id.id)
802                     message = message.parent_id
803                 parent_id = message.id
804
805         values = kwargs
806         values.update({
807             'model': model,
808             'res_id': thread_id or False,
809             'body': body,
810             'subject': subject or False,
811             'type': type,
812             'parent_id': parent_id,
813             'attachment_ids': attachment_ids,
814             'subtype_id': subtype_id,
815         })
816
817         # Avoid warnings about non-existing fields
818         for x in ('from', 'to', 'cc'):
819             values.pop(x, None)
820
821         return mail_message.create(cr, uid, values, context=context)
822
823     def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
824                                 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
825         """ Wrapper on message_post, used for user input :
826             - mail gateway
827             - quick reply in Chatter (refer to mail.js), not
828                 the mail.compose.message wizard
829             The purpose is to perform some pre- and post-processing:
830             - if body is plaintext: convert it into html
831             - if parent_id: handle reply to a previous message by adding the
832                 parent partners to the message
833             - type and subtype: comment and mail.mt_comment by default
834             - attachment_ids: supposed not attached to any document; attach them
835                 to the related document. Should only be set by Chatter.
836         """
837         ir_attachment = self.pool.get('ir.attachment')
838         mail_message = self.pool.get('mail.message')
839
840         # 1. Pre-processing: body, partner_ids, type and subtype
841         if content_subtype == 'plaintext':
842             body = tools.plaintext2html(body)
843
844         partner_ids = kwargs.pop('partner_ids', [])
845         if parent_id:
846             parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
847             partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
848             # TDE FIXME HACK: mail.thread -> private message
849             if self._name == 'mail.thread' and parent_message.author_id.id:
850                 partner_ids.append((4, parent_message.author_id.id))
851
852         message_type = kwargs.pop('type', 'comment')
853         message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
854
855         # 2. Post message
856         new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
857                         subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
858
859         # 3. Post-processing
860         # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
861         if attachment_ids:
862             # TDE FIXME (?): when posting a private message, we use mail.thread as a model
863             # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
864             model = self._name
865             if model == 'mail.thread':
866                 model = False
867             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
868                 ('res_model', '=', 'mail.compose.message'),
869                 ('res_id', '=', 0),
870                 ('create_uid', '=', uid),
871                 ('id', 'in', attachment_ids)], context=context)
872             if filtered_attachment_ids:
873                 if thread_id and model:
874                     ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
875                 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
876
877         return new_message_id
878
879     #------------------------------------------------------
880     # Followers API
881     #------------------------------------------------------
882
883     def message_get_subscription_data(self, cr, uid, ids, context=None):
884         """ Wrapper to get subtypes data. """
885         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
886
887     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
888         """ Wrapper on message_subscribe, using users. If user_ids is not
889             provided, subscribe uid instead. """
890         if user_ids is None:
891             user_ids = [uid]
892         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
893         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
894
895     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
896         """ Add partners to the records followers. """
897         self.write(cr, uid, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
898         # if subtypes are not specified (and not set to a void list), fetch default ones
899         if subtype_ids is None:
900             subtype_obj = self.pool.get('mail.message.subtype')
901             subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
902         # update the subscriptions
903         fol_obj = self.pool.get('mail.followers')
904         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
905         fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
906         return True
907
908     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
909         """ Wrapper on message_subscribe, using users. If user_ids is not
910             provided, unsubscribe uid instead. """
911         if user_ids is None:
912             user_ids = [uid]
913         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
914         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
915
916     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
917         """ Remove partners from the records followers. """
918         return self.write(cr, uid, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
919
920     #------------------------------------------------------
921     # Thread state
922     #------------------------------------------------------
923
924     def message_mark_as_unread(self, cr, uid, ids, context=None):
925         """ Set as unread. """
926         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
927         cr.execute('''
928             UPDATE mail_notification SET
929                 read=false
930             WHERE
931                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
932                 partner_id = %s
933         ''', (ids, self._name, partner_id))
934         return True
935
936     def message_mark_as_read(self, cr, uid, ids, context=None):
937         """ Set as read. """
938         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
939         cr.execute('''
940             UPDATE mail_notification SET
941                 read=true
942             WHERE
943                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
944                 partner_id = %s
945         ''', (ids, self._name, partner_id))
946         return True
947
948 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: