[IMP] mail, email_template: improved mass mailing action (renamed, now using a well...
[odoo/odoo.git] / addons / email_template / email_template.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2009 Sharoon Thomas
6 #    Copyright (C) 2010-Today OpenERP SA (<http://www.openerp.com>)
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU General Public License as published by
10 #    the Free Software Foundation, either version 3 of the License, or
11 #    (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU General Public License for more details.
17 #
18 #    You should have received a copy of the GNU General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
20 #
21 ##############################################################################
22
23 import base64
24 import logging
25
26 from openerp import netsvc
27 from openerp.osv import osv, fields
28 from openerp.osv import fields
29 from openerp import tools
30 from openerp.tools.translate import _
31 from urllib import urlencode, quote as quote
32
33 _logger = logging.getLogger(__name__)
34
35 try:
36     # We use a jinja2 sandboxed environment to render mako templates.
37     # Note that the rendering does not cover all the mako syntax, in particular
38     # arbitrary Python statements are not accepted, and not all expressions are
39     # allowed: only "public" attributes (not starting with '_') of objects may
40     # be accessed.
41     # This is done on purpose: it prevents incidental or malicious execution of
42     # Python code that may break the security of the server.
43     from jinja2.sandbox import SandboxedEnvironment
44     mako_template_env = SandboxedEnvironment(
45         block_start_string="<%",
46         block_end_string="%>",
47         variable_start_string="${",
48         variable_end_string="}",
49         comment_start_string="<%doc>",
50         comment_end_string="</%doc>",
51         line_statement_prefix="%",
52         line_comment_prefix="##",
53         trim_blocks=True,               # do not output newline after blocks
54         autoescape=True,                # XML/HTML automatic escaping
55     )
56     mako_template_env.globals.update({
57         'str': str,
58         'quote': quote,
59         'urlencode': urlencode,
60     })
61 except ImportError:
62     _logger.warning("jinja2 not available, templating features will not work!")
63
64 class email_template(osv.osv):
65     "Templates for sending email"
66     _name = "email.template"
67     _description = 'Email Templates'
68
69     def render_template(self, cr, uid, template, model, res_id, context=None):
70         """Render the given template text, replace mako expressions ``${expr}``
71            with the result of evaluating these expressions with
72            an evaluation context containing:
73
74                 * ``user``: browse_record of the current user
75                 * ``object``: browse_record of the document record this mail is
76                               related to
77                 * ``context``: the context passed to the mail composition wizard
78
79            :param str template: the template text to render
80            :param str model: model name of the document record this mail is related to.
81            :param int res_id: id of the document record this mail is related to.
82         """
83         if not template:
84             return u""
85         if context is None:
86             context = {}
87         try:
88             template = tools.ustr(template)
89             record = None
90             if res_id:
91                 record = self.pool.get(model).browse(cr, uid, res_id, context=context)
92             user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
93             variables = {
94                 'object': record,
95                 'user': user,
96                 'ctx': context,     # context kw would clash with mako internals
97             }
98             result = mako_template_env.from_string(template).render(variables)
99             if result == u"False":
100                 result = u""
101             return result
102         except Exception:
103             _logger.exception("failed to render mako template value %r", template)
104             return u""
105
106     def get_email_template(self, cr, uid, template_id=False, record_id=None, context=None):
107         if context is None:
108             context = {}
109         if not template_id:
110             return False
111         template = self.browse(cr, uid, template_id, context)
112         lang = self.render_template(cr, uid, template.lang, template.model, record_id, context)
113         if lang:
114             # Use translated template if necessary
115             ctx = context.copy()
116             ctx['lang'] = lang
117             template = self.browse(cr, uid, template.id, ctx)
118         else:
119             template = self.browse(cr, uid, int(template_id), context)
120         return template
121
122     def onchange_model_id(self, cr, uid, ids, model_id, context=None):
123         mod_name = False
124         if model_id:
125             mod_name = self.pool.get('ir.model').browse(cr, uid, model_id, context).model
126         return {'value': {'model': mod_name}}
127
128     _columns = {
129         'name': fields.char('Name'),
130         'model_id': fields.many2one('ir.model', 'Applies to', help="The kind of document with with this template can be used"),
131         'model': fields.related('model_id', 'model', type='char', string='Related Document Model',
132                                 size=128, select=True, store=True, readonly=True),
133         'lang': fields.char('Language',
134                             help="Optional translation language (ISO code) to select when sending out an email. "
135                                  "If not set, the english version will be used. "
136                                  "This should usually be a placeholder expression "
137                                  "that provides the appropriate language code, e.g. "
138                                  "${object.partner_id.lang.code}.",
139                             placeholder="${object.partner_id.lang.code}"),
140         'user_signature': fields.boolean('Add Signature',
141                                          help="If checked, the user's signature will be appended to the text version "
142                                               "of the message"),
143         'subject': fields.char('Subject', translate=True, help="Subject (placeholders may be used here)",),
144         'email_from': fields.char('From', help="Sender address (placeholders may be used here)"),
145         'email_to': fields.char('To (Emails)', help="Comma-separated recipient addresses (placeholders may be used here)"),
146         'partner_to': fields.char('To (Partners)',
147             help="Comma-separated ids of recipient partners (placeholders may be used here)",
148             oldname='email_recipients'),
149         'email_cc': fields.char('Cc', help="Carbon copy recipients (placeholders may be used here)"),
150         'reply_to': fields.char('Reply-To', help="Preferred response address (placeholders may be used here)"),
151         'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False,
152                                           help="Optional preferred server for outgoing mails. If not set, the highest "
153                                                "priority one will be used."),
154         'body_html': fields.text('Body', translate=True, help="Rich-text/HTML version of the message (placeholders may be used here)"),
155         'report_name': fields.char('Report Filename', translate=True,
156                                    help="Name to use for the generated report file (may contain placeholders)\n"
157                                         "The extension can be omitted and will then come from the report type."),
158         'report_template': fields.many2one('ir.actions.report.xml', 'Optional report to print and attach'),
159         'ref_ir_act_window': fields.many2one('ir.actions.act_window', 'Sidebar action', readonly=True,
160                                             help="Sidebar action to make this template available on records "
161                                                  "of the related document model"),
162         'ref_ir_value': fields.many2one('ir.values', 'Sidebar Button', readonly=True,
163                                        help="Sidebar button to open the sidebar action"),
164         'attachment_ids': fields.many2many('ir.attachment', 'email_template_attachment_rel', 'email_template_id',
165                                            'attachment_id', 'Attachments',
166                                            help="You may attach files to this template, to be added to all "
167                                                 "emails created from this template"),
168         'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
169
170         # Fake fields used to implement the placeholder assistant
171         'model_object_field': fields.many2one('ir.model.fields', string="Field",
172                                               help="Select target field from the related document model.\n"
173                                                    "If it is a relationship field you will be able to select "
174                                                    "a target field at the destination of the relationship."),
175         'sub_object': fields.many2one('ir.model', 'Sub-model', readonly=True,
176                                       help="When a relationship field is selected as first field, "
177                                            "this field shows the document model the relationship goes to."),
178         'sub_model_object_field': fields.many2one('ir.model.fields', 'Sub-field',
179                                                   help="When a relationship field is selected as first field, "
180                                                        "this field lets you select the target field within the "
181                                                        "destination document model (sub-model)."),
182         'null_value': fields.char('Default Value', help="Optional value to use if the target field is empty"),
183         'copyvalue': fields.char('Placeholder Expression', help="Final placeholder expression, to be copy-pasted in the desired template field."),
184     }
185
186     _defaults = {
187         'auto_delete': True,
188     }
189
190     def create_action(self, cr, uid, ids, context=None):
191         vals = {}
192         action_obj = self.pool.get('ir.actions.act_window')
193         data_obj = self.pool.get('ir.model.data')
194         for template in self.browse(cr, uid, ids, context=context):
195             src_obj = template.model_id.model
196             model_data_id = data_obj._get_id(cr, uid, 'mail', 'email_compose_message_wizard_form')
197             res_id = data_obj.browse(cr, uid, model_data_id, context=context).res_id
198             button_name = _('Send Mail (%s)') % template.name
199             vals['ref_ir_act_window'] = action_obj.create(cr, uid, {
200                  'name': button_name,
201                  'type': 'ir.actions.act_window',
202                  'res_model': 'mail.compose.message',
203                  'src_model': src_obj,
204                  'view_type': 'form',
205                  'context': "{'default_composition_mode': 'mass_mail', 'default_template_id' : %d, 'default_use_template': True}" % (template.id),
206                  'view_mode':'form,tree',
207                  'view_id': res_id,
208                  'target': 'new',
209                  'auto_refresh':1
210             }, context)
211             vals['ref_ir_value'] = self.pool.get('ir.values').create(cr, uid, {
212                  'name': button_name,
213                  'model': src_obj,
214                  'key2': 'client_action_multi',
215                  'value': "ir.actions.act_window," + str(vals['ref_ir_act_window']),
216                  'object': True,
217              }, context)
218         self.write(cr, uid, ids, {
219                     'ref_ir_act_window': vals.get('ref_ir_act_window',False),
220                     'ref_ir_value': vals.get('ref_ir_value',False),
221                 }, context)
222         return True
223
224     def unlink_action(self, cr, uid, ids, context=None):
225         for template in self.browse(cr, uid, ids, context=context):
226             try:
227                 if template.ref_ir_act_window:
228                     self.pool.get('ir.actions.act_window').unlink(cr, uid, template.ref_ir_act_window.id, context)
229                 if template.ref_ir_value:
230                     ir_values_obj = self.pool.get('ir.values')
231                     ir_values_obj.unlink(cr, uid, template.ref_ir_value.id, context)
232             except Exception:
233                 raise osv.except_osv(_("Warning"), _("Deletion of the action record failed."))
234         return True
235
236     def unlink(self, cr, uid, ids, context=None):
237         self.unlink_action(cr, uid, ids, context=context)
238         return super(email_template, self).unlink(cr, uid, ids, context=context)
239
240     def copy(self, cr, uid, id, default=None, context=None):
241         template = self.browse(cr, uid, id, context=context)
242         if default is None:
243             default = {}
244         default = default.copy()
245         default.update(
246             name=_("%s (copy)") % (template.name),
247             ref_ir_act_window=False,
248             ref_ir_value=False)
249         return super(email_template, self).copy(cr, uid, id, default, context)
250
251     def build_expression(self, field_name, sub_field_name, null_value):
252         """Returns a placeholder expression for use in a template field,
253            based on the values provided in the placeholder assistant.
254
255           :param field_name: main field name
256           :param sub_field_name: sub field name (M2O)
257           :param null_value: default value if the target value is empty
258           :return: final placeholder expression
259         """
260         expression = ''
261         if field_name:
262             expression = "${object." + field_name
263             if sub_field_name:
264                 expression += "." + sub_field_name
265             if null_value:
266                 expression += " or '''%s'''" % null_value
267             expression += "}"
268         return expression
269
270     def onchange_sub_model_object_value_field(self, cr, uid, ids, model_object_field, sub_model_object_field=False, null_value=None, context=None):
271         result = {
272             'sub_object': False,
273             'copyvalue': False,
274             'sub_model_object_field': False,
275             'null_value': False
276             }
277         if model_object_field:
278             fields_obj = self.pool.get('ir.model.fields')
279             field_value = fields_obj.browse(cr, uid, model_object_field, context)
280             if field_value.ttype in ['many2one', 'one2many', 'many2many']:
281                 res_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', field_value.relation)], context=context)
282                 sub_field_value = False
283                 if sub_model_object_field:
284                     sub_field_value = fields_obj.browse(cr, uid, sub_model_object_field, context)
285                 if res_ids:
286                     result.update({
287                         'sub_object': res_ids[0],
288                         'copyvalue': self.build_expression(field_value.name, sub_field_value and sub_field_value.name or False, null_value or False),
289                         'sub_model_object_field': sub_model_object_field or False,
290                         'null_value': null_value or False
291                         })
292             else:
293                 result.update({
294                         'copyvalue': self.build_expression(field_value.name, False, null_value or False),
295                         'null_value': null_value or False
296                         })
297         return {'value':result}
298
299
300     def generate_email(self, cr, uid, template_id, res_id, context=None):
301         """Generates an email from the template for given (model, res_id) pair.
302
303            :param template_id: id of the template to render.
304            :param res_id: id of the record to use for rendering the template (model
305                           is taken from template definition)
306            :returns: a dict containing all relevant fields for creating a new
307                      mail.mail entry, with one extra key ``attachments``, in the
308                      format expected by :py:meth:`mail_thread.message_post`.
309         """
310         if context is None:
311             context = {}
312         report_xml_pool = self.pool.get('ir.actions.report.xml')
313         template = self.get_email_template(cr, uid, template_id, res_id, context)
314         values = {}
315         for field in ['subject', 'body_html', 'email_from',
316                       'email_to', 'partner_to', 'email_cc', 'reply_to']:
317             values[field] = self.render_template(cr, uid, getattr(template, field),
318                                                  template.model, res_id, context=context) \
319                                                  or False
320         if template.user_signature:
321             signature = self.pool.get('res.users').browse(cr, uid, uid, context).signature
322             values['body_html'] = tools.append_content_to_html(values['body_html'], signature)
323
324         if values['body_html']:
325             values['body'] = tools.html_sanitize(values['body_html'])
326
327         values.update(mail_server_id=template.mail_server_id.id or False,
328                       auto_delete=template.auto_delete,
329                       model=template.model,
330                       res_id=res_id or False)
331
332         attachments = []
333         # Add report in attachments
334         if template.report_template:
335             report_name = self.render_template(cr, uid, template.report_name, template.model, res_id, context=context)
336             report_service = 'report.' + report_xml_pool.browse(cr, uid, template.report_template.id, context).report_name
337             # Ensure report is rendered using template's language
338             ctx = context.copy()
339             if template.lang:
340                 ctx['lang'] = self.render_template(cr, uid, template.lang, template.model, res_id, context)
341             service = netsvc.LocalService(report_service)
342             (result, format) = service.create(cr, uid, [res_id], {'model': template.model}, ctx)
343             result = base64.b64encode(result)
344             if not report_name:
345                 report_name = report_service
346             ext = "." + format
347             if not report_name.endswith(ext):
348                 report_name += ext
349             attachments.append((report_name, result))
350
351         # Add template attachments
352         for attach in template.attachment_ids:
353             attachments.append((attach.datas_fname, attach.datas))
354
355         values['attachments'] = attachments
356         return values
357
358     def send_mail(self, cr, uid, template_id, res_id, force_send=False, context=None):
359         """Generates a new mail message for the given template and record,
360            and schedules it for delivery through the ``mail`` module's scheduler.
361
362            :param int template_id: id of the template to render
363            :param int res_id: id of the record to render the template with
364                               (model is taken from the template)
365            :param bool force_send: if True, the generated mail.message is
366                 immediately sent after being created, as if the scheduler
367                 was executed for this message only.
368            :returns: id of the mail.message that was created
369         """
370         if context is None: context = {}
371         mail_mail = self.pool.get('mail.mail')
372         ir_attachment = self.pool.get('ir.attachment')
373         values = self.generate_email(cr, uid, template_id, res_id, context=context)
374         assert 'email_from' in values, 'email_from is missing or empty after template rendering, send_mail() cannot proceed'
375         attachments = values.pop('attachments') or {}
376         del values['partner_to'] # TODO Properly use them.
377         msg_id = mail_mail.create(cr, uid, values, context=context)
378         # link attachments
379         attachment_ids = []
380         for fname, fcontent in attachments.iteritems():
381             attachment_data = {
382                     'name': fname,
383                     'datas_fname': fname,
384                     'datas': fcontent,
385                     'res_model': mail_mail._name,
386                     'res_id': msg_id,
387             }
388             context.pop('default_type', None)
389             attachment_ids.append(ir_attachment.create(cr, uid, attachment_data, context=context))
390         if attachment_ids:
391             mail_mail.write(cr, uid, msg_id, {'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
392         if force_send:
393             mail_mail.send(cr, uid, [msg_id], context=context)
394         return msg_id
395
396 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: