[FIX] mail: when generating pure email mass mailing
[odoo/odoo.git] / addons / mail / wizard / mail_compose_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 General Public License as published by
9 #    the Free Software Foundation, either version 3 of the License, or
10 #    (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 General Public License for more details.
16 #
17 #    You should have received a copy of the GNU General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
19 #
20 ##############################################################################
21
22 import re
23
24 from openerp import tools
25 from openerp import SUPERUSER_ID
26 from openerp.osv import osv
27 from openerp.osv import fields
28 from openerp.tools.safe_eval import safe_eval as eval
29 from openerp.tools.translate import _
30
31 # main mako-like expression pattern
32 EXPRESSION_PATTERN = re.compile('(\$\{.+?\})')
33
34
35 class mail_compose_message(osv.TransientModel):
36     """ Generic message composition wizard. You may inherit from this wizard
37         at model and view levels to provide specific features.
38
39         The behavior of the wizard depends on the composition_mode field:
40         - 'reply': reply to a previous message. The wizard is pre-populated
41             via ``get_message_data``.
42         - 'comment': new post on a record. The wizard is pre-populated via
43             ``get_record_data``
44         - 'mass_mail': wizard in mass mailing mode where the mail details can
45             contain template placeholders that will be merged with actual data
46             before being sent to each recipient.
47     """
48     _name = 'mail.compose.message'
49     _inherit = 'mail.message'
50     _description = 'Email composition wizard'
51     _log_access = True
52
53     def default_get(self, cr, uid, fields, context=None):
54         """ Handle composition mode. Some details about context keys:
55             - comment: default mode, model and ID of a record the user comments
56                 - default_model or active_model
57                 - default_res_id or active_id
58             - reply: active_id of a message the user replies to
59                 - default_parent_id or message_id or active_id: ID of the
60                     mail.message we reply to
61                 - message.res_model or default_model
62                 - message.res_id or default_res_id
63             - mass_mail: model and IDs of records the user mass-mails
64                 - active_ids: record IDs
65                 - default_model or active_model
66         """
67         if context is None:
68             context = {}
69         result = super(mail_compose_message, self).default_get(cr, uid, fields, context=context)
70         # get some important values from context
71         composition_mode = context.get('default_composition_mode', context.get('mail.compose.message.mode'))
72         model = context.get('default_model', context.get('active_model'))
73         res_id = context.get('default_res_id', context.get('active_id'))
74         message_id = context.get('default_parent_id', context.get('message_id', context.get('active_id')))
75         active_ids = context.get('active_ids')
76         if 'active_domain' in context:  # not context.get() because we want to keep global [] domains
77             result['use_active_domain'] = True
78             result['active_domain'] = '%s' % context.get('active_domain')
79         elif not result.get('active_domain'):
80             result['active_domain'] = ''
81         # get default values according to the composition mode
82         if composition_mode == 'reply':
83             vals = self.get_message_data(cr, uid, message_id, context=context)
84         elif composition_mode == 'comment' and model and res_id:
85             vals = self.get_record_data(cr, uid, model, res_id, context=context)
86         elif composition_mode == 'mass_mail' and model and active_ids:
87             vals = {'model': model, 'res_id': res_id}
88         else:
89             vals = {'model': model, 'res_id': res_id}
90         if composition_mode:
91             vals['composition_mode'] = composition_mode
92
93         for field in vals:
94             if field in fields:
95                 result[field] = vals[field]
96
97         # TDE HACK: as mailboxes used default_model='res.users' and default_res_id=uid
98         # (because of lack of an accessible pid), creating a message on its own
99         # profile may crash (res_users does not allow writing on it)
100         # Posting on its own profile works (res_users redirect to res_partner)
101         # but when creating the mail.message to create the mail.compose.message
102         # access rights issues may rise
103         # We therefore directly change the model and res_id
104         if result.get('model') == 'res.users' and result.get('res_id') == uid:
105             result['model'] = 'res.partner'
106             result['res_id'] = self.pool.get('res.users').browse(cr, uid, uid).partner_id.id
107         return result
108
109     def _get_composition_mode_selection(self, cr, uid, context=None):
110         return [('comment', 'Comment a document'), ('reply', 'Reply to a message'), ('mass_mail', 'Mass mailing')]
111
112     _columns = {
113         'composition_mode': fields.selection(
114             lambda s, *a, **k: s._get_composition_mode_selection(*a, **k),
115             string='Composition mode'),
116         'partner_ids': fields.many2many('res.partner',
117             'mail_compose_message_res_partner_rel',
118             'wizard_id', 'partner_id', 'Additional contacts'),
119         'use_active_domain': fields.boolean('Use active domain'),
120         'active_domain': fields.char('Active domain', readonly=True),
121         'post': fields.boolean('Post a copy in the document',
122             help='Post a copy of the message on the document communication history.'),
123         'notify': fields.boolean('Notify followers',
124             help='Notify followers of the document'),
125         'same_thread': fields.boolean('Replies in the document',
126             help='Replies to the messages will go into the selected document.'),
127         'attachment_ids': fields.many2many('ir.attachment',
128             'mail_compose_message_ir_attachments_rel',
129             'wizard_id', 'attachment_id', 'Attachments'),
130         'filter_id': fields.many2one('ir.filters', 'Filters'),
131     }
132
133     _defaults = {
134         'composition_mode': 'comment',
135         'body': lambda self, cr, uid, ctx={}: '',
136         'subject': lambda self, cr, uid, ctx={}: False,
137         'partner_ids': lambda self, cr, uid, ctx={}: [],
138         'post': False,
139         'notify': False,
140         'same_thread': True,
141     }
142
143     def check_access_rule(self, cr, uid, ids, operation, context=None):
144         """ Access rules of mail.compose.message:
145             - create: if
146                 - model, no res_id, I create a message in mass mail mode
147             - then: fall back on mail.message acces rules
148         """
149         if isinstance(ids, (int, long)):
150             ids = [ids]
151
152         # Author condition (CREATE (mass_mail))
153         if operation == 'create' and uid != SUPERUSER_ID:
154             # read mail_compose_message.ids to have their values
155             message_values = {}
156             cr.execute('SELECT DISTINCT id, model, res_id FROM "%s" WHERE id = ANY (%%s) AND res_id = 0' % self._table, (ids,))
157             for id, rmod, rid in cr.fetchall():
158                 message_values[id] = {'model': rmod, 'res_id': rid}
159             # remove from the set to check the ids that mail_compose_message accepts
160             author_ids = [mid for mid, message in message_values.iteritems()
161                 if message.get('model') and not message.get('res_id')]
162             ids = list(set(ids) - set(author_ids))
163
164         return super(mail_compose_message, self).check_access_rule(cr, uid, ids, operation, context=context)
165
166     def _notify(self, cr, uid, newid, context=None, force_send=False, user_signature=True):
167         """ Override specific notify method of mail.message, because we do
168             not want that feature in the wizard. """
169         return
170
171     def get_record_data(self, cr, uid, model, res_id, context=None):
172         """ Returns a defaults-like dict with initial values for the composition
173             wizard when sending an email related to the document record
174             identified by ``model`` and ``res_id``.
175
176             :param str model: model name of the document record this mail is
177                 related to.
178             :param int res_id: id of the document record this mail is related to
179         """
180         doc_name_get = self.pool[model].name_get(cr, uid, [res_id], context=context)
181         record_name = False
182         if doc_name_get:
183             record_name = doc_name_get[0][1]
184         values = {
185             'model': model,
186             'res_id': res_id,
187             'record_name': record_name,
188         }
189         if record_name:
190             values['subject'] = 'Re: %s' % record_name
191         return values
192
193     def get_message_data(self, cr, uid, message_id, context=None):
194         """ Returns a defaults-like dict with initial values for the composition
195             wizard when replying to the given message (e.g. including the quote
196             of the initial message, and the correct recipients).
197
198             :param int message_id: id of the mail.message to which the user
199                 is replying.
200         """
201         if not message_id:
202             return {}
203         if context is None:
204             context = {}
205         message_data = self.pool.get('mail.message').browse(cr, uid, message_id, context=context)
206
207         # create subject
208         re_prefix = _('Re:')
209         reply_subject = tools.ustr(message_data.subject or message_data.record_name or '')
210         if not (reply_subject.startswith('Re:') or reply_subject.startswith(re_prefix)) and message_data.subject:
211             reply_subject = "%s %s" % (re_prefix, reply_subject)
212         # get partner_ids from original message
213         partner_ids = [partner.id for partner in message_data.partner_ids] if message_data.partner_ids else []
214         partner_ids += context.get('default_partner_ids', [])
215         if context.get('is_private',False) and message_data.author_id : #check message is private then add author also in partner list.
216             partner_ids += [message_data.author_id.id]
217         # update the result
218         result = {
219             'record_name': message_data.record_name,
220             'model': message_data.model,
221             'res_id': message_data.res_id,
222             'parent_id': message_data.id,
223             'subject': reply_subject,
224             'partner_ids': partner_ids,
225         }
226         return result
227
228     #------------------------------------------------------
229     # Wizard validation and send
230     #------------------------------------------------------
231
232     def send_mail(self, cr, uid, ids, context=None):
233         """ Process the wizard content and proceed with sending the related
234             email(s), rendering any template patterns on the fly if needed. """
235         if context is None:
236             context = {}
237         # clean the context (hint: mass mailing sets some default values that
238         # could be wrongly interpreted by mail_mail)
239         context.pop('default_email_to', None)
240         context.pop('default_partner_ids', None)
241
242         active_ids = context.get('active_ids')
243         is_log = context.get('mail_compose_log', False)
244
245         for wizard in self.browse(cr, uid, ids, context=context):
246             mass_mail_mode = wizard.composition_mode == 'mass_mail'
247             active_model_pool = self.pool[wizard.model if wizard.model else 'mail.thread']
248             if not hasattr(active_model_pool, 'message_post'):
249                 context['thread_model'] = wizard.model
250                 active_model_pool = self.pool['mail.thread']
251
252             # wizard works in batch mode: [res_id] or active_ids or active_domain
253             if mass_mail_mode and wizard.use_active_domain and wizard.model:
254                 res_ids = self.pool[wizard.model].search(cr, uid, eval(wizard.active_domain), context=context)
255             elif mass_mail_mode and wizard.model and active_ids:
256                 res_ids = active_ids
257             else:
258                 res_ids = [wizard.res_id]
259
260             all_mail_values = self.get_mail_values(cr, uid, wizard, res_ids, context=context)
261
262             for res_id, mail_values in all_mail_values.iteritems():
263                 if mass_mail_mode and not wizard.post:
264                     # process attachments and attachment_ids before sending values to mail_mail.Lcreat
265                     m2m_attachment_ids = self.pool['mail.thread']._message_preprocess_attachments(
266                         cr, uid, mail_values.pop('attachments', []),
267                         mail_values.pop('attachment_ids', []),
268                         'mail.message', 0,
269                         context=context)
270                     mail_values['attachment_ids'] = m2m_attachment_ids
271                     self.pool.get('mail.mail').create(cr, uid, mail_values, context=context)
272                 else:
273                     subtype = 'mail.mt_comment'
274                     if is_log:  # log a note: subtype is False
275                         subtype = False
276                     elif mass_mail_mode:  # mass mail: is a log pushed to recipients unless specified, author not added
277                         if not wizard.notify:
278                             subtype = False
279                         context = dict(context,
280                                        mail_notify_force_send=False,  # do not send emails directly but use the queue instead
281                                        mail_create_nosubscribe=True)  # add context key to avoid subscribing the author
282                     active_model_pool.message_post(cr, uid, [res_id], type='comment', subtype=subtype, context=context, **mail_values)
283
284         return {'type': 'ir.actions.act_window_close'}
285
286     def get_mail_values(self, cr, uid, wizard, res_ids, context=None):
287         """Generate the values that will be used by send_mail to create mail_messages
288         or mail_mails. """
289         results = dict.fromkeys(res_ids, False)
290         mass_mail_mode = wizard.composition_mode == 'mass_mail'
291
292         # render all template-based value at once
293         if mass_mail_mode and wizard.model:
294             rendered_values = self.render_message_batch(cr, uid, wizard, res_ids, context=context)
295
296         for res_id in res_ids:
297             # static wizard (mail.message) values
298             mail_values = {
299                 'subject': wizard.subject,
300                 'body': wizard.body,
301                 'parent_id': wizard.parent_id and wizard.parent_id.id,
302                 'partner_ids': [partner.id for partner in wizard.partner_ids],
303                 'attachment_ids': [attach.id for attach in wizard.attachment_ids],
304             }
305             # mass mailing: rendering override wizard static values
306             if mass_mail_mode and wizard.model:
307                 email_dict = rendered_values[res_id]
308                 mail_values['partner_ids'] += email_dict.pop('partner_ids', [])
309                 mail_values['attachments'] = email_dict.pop('attachments', [])
310                 attachment_ids = []
311                 for attach_id in mail_values.pop('attachment_ids'):
312                     new_attach_id = self.pool.get('ir.attachment').copy(cr, uid, attach_id, {'res_model': self._name, 'res_id': wizard.id}, context=context)
313                     attachment_ids.append(new_attach_id)
314                 mail_values['attachment_ids'] = attachment_ids
315                 # email_from: mass mailing only can specify another email_from
316                 if email_dict.get('email_from'):
317                     mail_values['email_from'] = email_dict.pop('email_from')
318                 # replies redirection: mass mailing only
319                 if not wizard.same_thread:
320                     mail_values['reply_to'] = email_dict.pop('reply_to')
321                 else:
322                     email_dict.pop('reply_to')
323                 mail_values.update(email_dict)
324             # mass mailing without post: mail_mail values
325             if mass_mail_mode and not wizard.post:
326                 if 'mail_auto_delete' in context:
327                     mail_values['auto_delete'] = context.get('mail_auto_delete')
328                 mail_values['body_html'] = mail_values.get('body', '')
329                 mail_values['recipient_ids'] = [(4, id) for id in mail_values.pop('partner_ids', [])]
330             results[res_id] = mail_values
331         return results
332
333     def render_message_batch(self, cr, uid, wizard, res_ids, context=None):
334         """Generate template-based values of wizard, for the document records given
335         by res_ids. This method is meant to be inherited by email_template that
336         will produce a more complete dictionary, using Jinja2 templates.
337
338         Each template is generated for all res_ids, allowing to parse the template
339         once, and render it multiple times. This is useful for mass mailing where
340         template rendering represent a significant part of the process.
341
342         :param browse wizard: current mail.compose.message browse record
343         :param list res_ids: list of record ids
344
345         :return dict results: for each res_id, the generated template values for
346                               subject, body, email_from and reply_to
347         """
348         subjects = self.render_template_batch(cr, uid, wizard.subject, wizard.model, res_ids, context)
349         bodies = self.render_template_batch(cr, uid, wizard.body, wizard.model, res_ids, context)
350         emails_from = self.render_template_batch(cr, uid, wizard.email_from, wizard.model, res_ids, context)
351         replies_to = self.render_template_batch(cr, uid, wizard.reply_to, wizard.model, res_ids, context)
352
353         results = dict.fromkeys(res_ids, False)
354         for res_id in res_ids:
355             results[res_id] = {
356                 'subject': subjects[res_id],
357                 'body': bodies[res_id],
358                 'email_from': emails_from[res_id],
359                 'reply_to': replies_to[res_id],
360             }
361         return results
362
363     def render_template_batch(self, cr, uid, template, model, res_ids, context=None):
364         """ Render the given template text, replace mako-like expressions ``${expr}``
365         with the result of evaluating these expressions with an evaluation context
366         containing:
367
368             * ``user``: browse_record of the current user
369             * ``object``: browse_record of the document record this mail is
370                           related to
371             * ``context``: the context passed to the mail composition wizard
372
373         :param str template: the template text to render
374         :param str model: model name of the document record this mail is related to
375         :param list res_ids: list of record ids
376         """
377         if context is None:
378             context = {}
379         results = dict.fromkeys(res_ids, False)
380
381         for res_id in res_ids:
382             def merge(match):
383                 exp = str(match.group()[2:-1]).strip()
384                 result = eval(exp, {
385                     'user': self.pool.get('res.users').browse(cr, uid, uid, context=context),
386                     'object': self.pool[model].browse(cr, uid, res_id, context=context),
387                     'context': dict(context),  # copy context to prevent side-effects of eval
388                 })
389                 return result and tools.ustr(result) or ''
390             results[res_id] = template and EXPRESSION_PATTERN.sub(merge, template)
391         return results
392
393     # Compatibility methods
394     def render_template(self, cr, uid, template, model, res_id, context=None):
395         return self.render_template_batch(cr, uid, template, model, [res_id], context)[res_id]
396
397     def render_message(self, cr, uid, wizard, res_id, context=None):
398         return self.render_message_batch(cr, uid, wizard, [res_id], context)[res_id]