[MERGE] forward port of branch 8.0 up to 2e092ac
[odoo/odoo.git] / addons / mail / wizard / mail_compose_message.py
index 629dd6a..4fc7de7 100644 (file)
@@ -21,8 +21,8 @@
 
 import base64
 import re
 
 import base64
 import re
-from openerp import tools
 
 
+from openerp import tools
 from openerp import SUPERUSER_ID
 from openerp.osv import osv
 from openerp.osv import fields
 from openerp import SUPERUSER_ID
 from openerp.osv import osv
 from openerp.osv import fields
@@ -38,10 +38,7 @@ class mail_compose_message(osv.TransientModel):
         at model and view levels to provide specific features.
 
         The behavior of the wizard depends on the composition_mode field:
         at model and view levels to provide specific features.
 
         The behavior of the wizard depends on the composition_mode field:
-        - 'reply': reply to a previous message. The wizard is pre-populated
-            via ``get_message_data``.
-        - 'comment': new post on a record. The wizard is pre-populated via
-            ``get_record_data``
+        - 'comment': post on a record. The wizard is pre-populated via ``get_record_data``
         - 'mass_mail': wizard in mass mailing mode where the mail details can
             contain template placeholders that will be merged with actual data
             before being sent to each recipient.
         - 'mass_mail': wizard in mass mailing mode where the mail details can
             contain template placeholders that will be merged with actual data
             before being sent to each recipient.
@@ -50,6 +47,7 @@ class mail_compose_message(osv.TransientModel):
     _inherit = 'mail.message'
     _description = 'Email composition wizard'
     _log_access = True
     _inherit = 'mail.message'
     _description = 'Email composition wizard'
     _log_access = True
+    _batch_size = 500
 
     def default_get(self, cr, uid, fields, context=None):
         """ Handle composition mode. Some details about context keys:
 
     def default_get(self, cr, uid, fields, context=None):
         """ Handle composition mode. Some details about context keys:
@@ -69,24 +67,24 @@ class mail_compose_message(osv.TransientModel):
             context = {}
         result = super(mail_compose_message, self).default_get(cr, uid, fields, context=context)
 
             context = {}
         result = super(mail_compose_message, self).default_get(cr, uid, fields, context=context)
 
-        # get some important values from context
-        composition_mode = context.get('default_composition_mode', context.get('mail.compose.message.mode'))
-        model = context.get('default_model', context.get('active_model'))
-        res_id = context.get('default_res_id', context.get('active_id'))
-        message_id = context.get('default_parent_id', context.get('message_id', context.get('active_id')))
-        active_ids = context.get('active_ids')
-
-        # get default values according to the composition mode
-        if composition_mode == 'reply':
-            vals = self.get_message_data(cr, uid, message_id, context=context)
-        elif composition_mode == 'comment' and model and res_id:
-            vals = self.get_record_data(cr, uid, model, res_id, context=context)
-        elif composition_mode == 'mass_mail' and model and active_ids:
-            vals = {'model': model, 'res_id': res_id}
-        else:
-            vals = {'model': model, 'res_id': res_id}
-        if composition_mode:
-            vals['composition_mode'] = composition_mode
+        # v6.1 compatibility mode
+        result['composition_mode'] = result.get('composition_mode', context.get('mail.compose.message.mode', 'comment'))
+        result['model'] = result.get('model', context.get('active_model'))
+        result['res_id'] = result.get('res_id', context.get('active_id'))
+        result['parent_id'] = result.get('parent_id', context.get('message_id'))
+
+        if not result['model'] or not self.pool.get(result['model']) or not hasattr(self.pool[result['model']], 'message_post'):
+            result['no_auto_thread'] = True
+
+        # default values according to composition mode - NOTE: reply is deprecated, fall back on comment
+        if result['composition_mode'] == 'reply':
+            result['composition_mode'] = 'comment'
+        vals = {}
+        if 'active_domain' in context:  # not context.get() because we want to keep global [] domains
+            vals['use_active_domain'] = True
+            vals['active_domain'] = '%s' % context.get('active_domain')
+        if result['composition_mode'] == 'comment':
+            vals.update(self.get_record_data(cr, uid, result, context=context))
 
         for field in vals:
             if field in fields:
 
         for field in vals:
             if field in fields:
@@ -99,13 +97,18 @@ class mail_compose_message(osv.TransientModel):
         # but when creating the mail.message to create the mail.compose.message
         # access rights issues may rise
         # We therefore directly change the model and res_id
         # but when creating the mail.message to create the mail.compose.message
         # access rights issues may rise
         # We therefore directly change the model and res_id
-        if result.get('model') == 'res.users' and result.get('res_id') == uid:
+        if result['model'] == 'res.users' and result['res_id'] == uid:
             result['model'] = 'res.partner'
             result['res_id'] = self.pool.get('res.users').browse(cr, uid, uid).partner_id.id
             result['model'] = 'res.partner'
             result['res_id'] = self.pool.get('res.users').browse(cr, uid, uid).partner_id.id
+
+        if fields is not None:
+            [result.pop(field, None) for field in result.keys() if field not in fields]
         return result
 
     def _get_composition_mode_selection(self, cr, uid, context=None):
         return result
 
     def _get_composition_mode_selection(self, cr, uid, context=None):
-        return [('comment', 'Comment a document'), ('reply', 'Reply to a message'), ('mass_mail', 'Mass mailing')]
+        return [('comment', 'Post on a document'),
+                ('mass_mail', 'Email Mass Mailing'),
+                ('mass_post', 'Post on Multiple Documents')]
 
     _columns = {
         'composition_mode': fields.selection(
 
     _columns = {
         'composition_mode': fields.selection(
@@ -113,28 +116,23 @@ class mail_compose_message(osv.TransientModel):
             string='Composition mode'),
         'partner_ids': fields.many2many('res.partner',
             'mail_compose_message_res_partner_rel',
             string='Composition mode'),
         'partner_ids': fields.many2many('res.partner',
             'mail_compose_message_res_partner_rel',
-            'wizard_id', 'partner_id', 'Additional contacts'),
-        'post': fields.boolean('Post a copy in the document',
-            help='Post a copy of the message on the document communication history.'),
-        'notify': fields.boolean('Notify followers',
-            help='Notify followers of the document'),
-        'same_thread': fields.boolean('Replies in the document',
-            help='Replies to the messages will go into the selected document.'),
+            'wizard_id', 'partner_id', 'Additional Contacts'),
+        'use_active_domain': fields.boolean('Use active domain'),
+        'active_domain': fields.char('Active domain', readonly=True),
         'attachment_ids': fields.many2many('ir.attachment',
             'mail_compose_message_ir_attachments_rel',
             'wizard_id', 'attachment_id', 'Attachments'),
         'attachment_ids': fields.many2many('ir.attachment',
             'mail_compose_message_ir_attachments_rel',
             'wizard_id', 'attachment_id', 'Attachments'),
-        'filter_id': fields.many2one('ir.filters', 'Filters'),
+        'is_log': fields.boolean('Log an Internal Note',
+                                 help='Whether the message is an internal note (comment mode only)'),
+        # mass mode options
+        'notify': fields.boolean('Notify followers',
+            help='Notify followers of the document (mass post only)'),
     }
     }
-
     _defaults = {
         'composition_mode': 'comment',
     _defaults = {
         'composition_mode': 'comment',
-        'email_from': lambda self, cr, uid, ctx={}: self.pool.get('mail.mail')._get_default_from(cr, uid, context=ctx),
         'body': lambda self, cr, uid, ctx={}: '',
         'subject': lambda self, cr, uid, ctx={}: False,
         'partner_ids': lambda self, cr, uid, ctx={}: [],
         'body': lambda self, cr, uid, ctx={}: '',
         'subject': lambda self, cr, uid, ctx={}: False,
         'partner_ids': lambda self, cr, uid, ctx={}: [],
-        'notify': lambda self, cr, uid, ctx={}: False,
-        'post': lambda self, cr, uid, ctx={}: True,
-        'same_thread': lambda self, cr, uid, ctx={}: True,
     }
 
     def check_access_rule(self, cr, uid, ids, operation, context=None):
     }
 
     def check_access_rule(self, cr, uid, ids, operation, context=None):
@@ -160,65 +158,41 @@ class mail_compose_message(osv.TransientModel):
 
         return super(mail_compose_message, self).check_access_rule(cr, uid, ids, operation, context=context)
 
 
         return super(mail_compose_message, self).check_access_rule(cr, uid, ids, operation, context=context)
 
-    def _notify(self, cr, uid, newid, context=None):
+    def _notify(self, cr, uid, newid, context=None, force_send=False, user_signature=True):
         """ Override specific notify method of mail.message, because we do
             not want that feature in the wizard. """
         return
 
         """ Override specific notify method of mail.message, because we do
             not want that feature in the wizard. """
         return
 
-    def get_record_data(self, cr, uid, model, res_id, context=None):
+    def get_record_data(self, cr, uid, values, context=None):
         """ Returns a defaults-like dict with initial values for the composition
         """ Returns a defaults-like dict with initial values for the composition
-            wizard when sending an email related to the document record
-            identified by ``model`` and ``res_id``.
-
-            :param str model: model name of the document record this mail is
-                related to.
-            :param int res_id: id of the document record this mail is related to
-        """
-        doc_name_get = self.pool.get(model).name_get(cr, uid, [res_id], context=context)
-        record_name = False
-        if doc_name_get:
-            record_name = doc_name_get[0][1]
-        values = {
-            'model': model,
-            'res_id': res_id,
-            'record_name': record_name,
-        }
-        if record_name:
-            values['subject'] = 'Re: %s' % record_name
-        return values
-
-    def get_message_data(self, cr, uid, message_id, context=None):
-        """ Returns a defaults-like dict with initial values for the composition
-            wizard when replying to the given message (e.g. including the quote
-            of the initial message, and the correct recipients).
-
-            :param int message_id: id of the mail.message to which the user
-                is replying.
-        """
-        if not message_id:
-            return {}
+        wizard when sending an email related a previous email (parent_id) or
+        a document (model, res_id). This is based on previously computed default
+        values. """
         if context is None:
             context = {}
         if context is None:
             context = {}
-        message_data = self.pool.get('mail.message').browse(cr, uid, message_id, context=context)
+        result, subject = {}, False
+        if values.get('parent_id'):
+            parent = self.pool.get('mail.message').browse(cr, uid, values.get('parent_id'), context=context)
+            result['record_name'] = parent.record_name,
+            subject = tools.ustr(parent.subject or parent.record_name or '')
+            if not values.get('model'):
+                result['model'] = parent.model
+            if not values.get('res_id'):
+                result['res_id'] = parent.res_id
+            partner_ids = values.get('partner_ids', list()) + [partner.id for partner in parent.partner_ids]
+            if context.get('is_private') and parent.author_id:  # check message is private then add author also in partner list.
+                partner_ids += [parent.author_id.id]
+            result['partner_ids'] = partner_ids
+        elif values.get('model') and values.get('res_id'):
+            doc_name_get = self.pool[values.get('model')].name_get(cr, uid, [values.get('res_id')], context=context)
+            result['record_name'] = doc_name_get and doc_name_get[0][1] or ''
+            subject = tools.ustr(result['record_name'])
 
 
-        # create subject
         re_prefix = _('Re:')
         re_prefix = _('Re:')
-        reply_subject = tools.ustr(message_data.subject or message_data.record_name or '')
-        if not (reply_subject.startswith('Re:') or reply_subject.startswith(re_prefix)) and message_data.subject:
-            reply_subject = "%s %s" % (re_prefix, reply_subject)
-        # get partner_ids from original message
-        partner_ids = [partner.id for partner in message_data.partner_ids] if message_data.partner_ids else []
-        partner_ids += context.get('default_partner_ids', [])
-
-        # update the result
-        result = {
-            'record_name': message_data.record_name,
-            'model': message_data.model,
-            'res_id': message_data.res_id,
-            'parent_id': message_data.id,
-            'subject': reply_subject,
-            'partner_ids': partner_ids,
-        }
+        if subject and not (subject.startswith('Re:') or subject.startswith(re_prefix)):
+            subject = "%s %s" % (re_prefix, subject)
+        result['subject'] = subject
+
         return result
 
     #------------------------------------------------------
         return result
 
     #------------------------------------------------------
@@ -228,99 +202,185 @@ class mail_compose_message(osv.TransientModel):
     def send_mail(self, cr, uid, ids, context=None):
         """ Process the wizard content and proceed with sending the related
             email(s), rendering any template patterns on the fly if needed. """
     def send_mail(self, cr, uid, ids, context=None):
         """ Process the wizard content and proceed with sending the related
             email(s), rendering any template patterns on the fly if needed. """
-        if context is None:
-            context = {}
-        active_ids = context.get('active_ids')
-        is_log = context.get('mail_compose_log', False)
+        context = dict(context or {})
+
+        # clean the context (hint: mass mailing sets some default values that
+        # could be wrongly interpreted by mail_mail)
+        context.pop('default_email_to', None)
+        context.pop('default_partner_ids', None)
 
         for wizard in self.browse(cr, uid, ids, context=context):
 
         for wizard in self.browse(cr, uid, ids, context=context):
-            mass_mail_mode = wizard.composition_mode == 'mass_mail'
-            if mass_mail_mode:  # mass mail: avoid any auto subscription because this could lead to people being follower of plenty of documents
-                context['mail_create_nosubscribe'] = True
-            active_model_pool = self.pool.get(wizard.model if wizard.model else 'mail.thread')
-
-            # wizard works in batch mode: [res_id] or active_ids
-            res_ids = active_ids if mass_mail_mode and wizard.model and active_ids else [wizard.res_id]
-            for res_id in res_ids:
-                # default values, according to the wizard options
-                post_values = {
-                    'subject': wizard.subject,
-                    'body': wizard.body,
-                    'parent_id': wizard.parent_id and wizard.parent_id.id,
-                    'partner_ids': [partner.id for partner in wizard.partner_ids],
-                    'attachments': [(attach.datas_fname or attach.name, base64.b64decode(attach.datas)) for attach in wizard.attachment_ids],
-                }
-                # mass mailing: render and override default values
-                if mass_mail_mode and wizard.model:
-                    email_dict = self.render_message(cr, uid, wizard, res_id, context=context)
-                    new_partner_ids = email_dict.pop('partner_ids', [])
-                    post_values['partner_ids'] += new_partner_ids
-                    new_attachments = email_dict.pop('attachments', [])
-                    post_values['attachments'] += new_attachments
-                    post_values.update(email_dict)
-                    # email_from: mass mailing only can specify another email_from
-                    if email_dict.get('email_from'):
-                        post_values['email_from'] = email_dict.pop('email_from')
-                    # replies redirection: mass mailing only
-                    if not wizard.same_thread:
-                        post_values['reply_to'] = email_dict.pop('reply_to')
-                # clean the context (hint: mass mailing sets some default values that
-                # could be wrongly interpreted by mail_mail)
-                context.pop('default_email_to', None)
-                context.pop('default_partner_ids', None)
-                # post the message
-                if mass_mail_mode and not wizard.post:
-                    post_values['recipient_ids'] = [(4, id) for id in post_values.pop('partner_ids', [])]
-                    self.pool.get('mail.mail').create(cr, uid, post_values, context=context)
-                else:
-                    subtype = 'mail.mt_comment'
-                    if is_log:  # log a note: subtype is False
-                        subtype = False
-                    elif mass_mail_mode:  # mass mail: is a log pushed to recipients unless specified, author not added
-                        if not wizard.notify:
+            mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post')
+            active_model_pool = self.pool[wizard.model if wizard.model else 'mail.thread']
+            if not hasattr(active_model_pool, 'message_post'):
+                context['thread_model'] = wizard.model
+                active_model_pool = self.pool['mail.thread']
+
+            # wizard works in batch mode: [res_id] or active_ids or active_domain
+            if mass_mode and wizard.use_active_domain and wizard.model:
+                res_ids = self.pool[wizard.model].search(cr, uid, eval(wizard.active_domain), context=context)
+            elif mass_mode and wizard.model and context.get('active_ids'):
+                res_ids = context['active_ids']
+            else:
+                res_ids = [wizard.res_id]
+
+            batch_size = int(self.pool['ir.config_parameter'].get_param(cr, SUPERUSER_ID, 'mail.batch_size')) or self._batch_size
+
+            sliced_res_ids = [res_ids[i:i + batch_size] for i in range(0, len(res_ids), batch_size)]
+            for res_ids in sliced_res_ids:
+                all_mail_values = self.get_mail_values(cr, uid, wizard, res_ids, context=context)
+                for res_id, mail_values in all_mail_values.iteritems():
+                    if wizard.composition_mode == 'mass_mail':
+                        self.pool['mail.mail'].create(cr, uid, mail_values, context=context)
+                    else:
+                        subtype = 'mail.mt_comment'
+                        if context.get('mail_compose_log') or (wizard.composition_mode == 'mass_post' and not wizard.notify):  # log a note: subtype is False
                             subtype = False
                             subtype = False
-                        context = dict(context, mail_create_nosubscribe=True)  # add context key to avoid subscribing the author
-                    msg_id = active_model_pool.message_post(cr, uid, [res_id], type='comment', subtype=subtype, context=context, **post_values)
-                    # mass_mailing, post without notify: notify specific partners
-                    if mass_mail_mode and not wizard.notify and post_values['partner_ids']:
-                        self.pool.get('mail.notification')._notify(cr, uid, msg_id, post_values['partner_ids'], context=context)
+                        if wizard.composition_mode == 'mass_post':
+                            context = dict(context,
+                                           mail_notify_force_send=False,  # do not send emails directly but use the queue instead
+                                           mail_create_nosubscribe=True)  # add context key to avoid subscribing the author
+                        active_model_pool.message_post(cr, uid, [res_id], type='comment', subtype=subtype, context=context, **mail_values)
 
         return {'type': 'ir.actions.act_window_close'}
 
 
         return {'type': 'ir.actions.act_window_close'}
 
-    def render_message(self, cr, uid, wizard, res_id, context=None):
-        """ Generate an email from the template for given (wizard.model, res_id)
-            pair. This method is meant to be inherited by email_template that
-            will produce a more complete dictionary. """
-        return {
-            'subject': self.render_template(cr, uid, wizard.subject, wizard.model, res_id, context),
-            'body': self.render_template(cr, uid, wizard.body, wizard.model, res_id, context),
-            'email_from': self.render_template(cr, uid, wizard.email_from, wizard.model, res_id, context),
-            'reply_to': self.render_template(cr, uid, wizard.reply_to, wizard.model, res_id, context),
-        }
+    def get_mail_values(self, cr, uid, wizard, res_ids, context=None):
+        """Generate the values that will be used by send_mail to create mail_messages
+        or mail_mails. """
+        results = dict.fromkeys(res_ids, False)
+        rendered_values, default_recipients = {}, {}
+        mass_mail_mode = wizard.composition_mode == 'mass_mail'
+
+        # render all template-based value at once
+        if mass_mail_mode and wizard.model:
+            rendered_values = self.render_message_batch(cr, uid, wizard, res_ids, context=context)
+        # compute alias-based reply-to in batch
+        reply_to_value = dict.fromkeys(res_ids, None)
+        if mass_mail_mode and not wizard.no_auto_thread:
+            reply_to_value = self.pool['mail.thread'].message_get_reply_to(cr, uid, res_ids, default=wizard.email_from, context=dict(context, thread_model=wizard.model))
+
+        for res_id in res_ids:
+            # static wizard (mail.message) values
+            mail_values = {
+                'subject': wizard.subject,
+                'body': wizard.body,
+                'parent_id': wizard.parent_id and wizard.parent_id.id,
+                'partner_ids': [partner.id for partner in wizard.partner_ids],
+                'attachment_ids': [attach.id for attach in wizard.attachment_ids],
+                'author_id': wizard.author_id.id,
+                'email_from': wizard.email_from,
+                'record_name': wizard.record_name,
+                'no_auto_thread': wizard.no_auto_thread,
+            }
+            # mass mailing: rendering override wizard static values
+            if mass_mail_mode and wizard.model:
+                # always keep a copy, reset record name (avoid browsing records)
+                mail_values.update(notification=True, model=wizard.model, res_id=res_id, record_name=False)
+                # auto deletion of mail_mail
+                if 'mail_auto_delete' in context:
+                    mail_values['auto_delete'] = context.get('mail_auto_delete')
+                # rendered values using template
+                email_dict = rendered_values[res_id]
+                mail_values['partner_ids'] += email_dict.pop('partner_ids', [])
+                mail_values.update(email_dict)
+                if not wizard.no_auto_thread:
+                    mail_values.pop('reply_to')
+                    if reply_to_value.get(res_id):
+                        mail_values['reply_to'] = reply_to_value[res_id]
+                if wizard.no_auto_thread and not mail_values.get('reply_to'):
+                    mail_values['reply_to'] = mail_values['email_from']
+                # mail_mail values: body -> body_html, partner_ids -> recipient_ids
+                mail_values['body_html'] = mail_values.get('body', '')
+                mail_values['recipient_ids'] = [(4, id) for id in mail_values.pop('partner_ids', [])]
+
+                # process attachments: should not be encoded before being processed by message_post / mail_mail create
+                mail_values['attachments'] = [(name, base64.b64decode(enc_cont)) for name, enc_cont in email_dict.pop('attachments', list())]
+                attachment_ids = []
+                for attach_id in mail_values.pop('attachment_ids'):
+                    new_attach_id = self.pool.get('ir.attachment').copy(cr, uid, attach_id, {'res_model': self._name, 'res_id': wizard.id}, context=context)
+                    attachment_ids.append(new_attach_id)
+                mail_values['attachment_ids'] = self.pool['mail.thread']._message_preprocess_attachments(
+                    cr, uid, mail_values.pop('attachments', []),
+                    attachment_ids, 'mail.message', 0, context=context)
+
+            results[res_id] = mail_values
+        return results
 
 
-    def render_template(self, cr, uid, template, model, res_id, context=None):
+    #------------------------------------------------------
+    # Template rendering
+    #------------------------------------------------------
+
+    def render_message_batch(self, cr, uid, wizard, res_ids, context=None):
+        """Generate template-based values of wizard, for the document records given
+        by res_ids. This method is meant to be inherited by email_template that
+        will produce a more complete dictionary, using Jinja2 templates.
+
+        Each template is generated for all res_ids, allowing to parse the template
+        once, and render it multiple times. This is useful for mass mailing where
+        template rendering represent a significant part of the process.
+
+        Default recipients are also computed, based on mail_thread method
+        message_get_default_recipients. This allows to ensure a mass mailing has
+        always some recipients specified.
+
+        :param browse wizard: current mail.compose.message browse record
+        :param list res_ids: list of record ids
+
+        :return dict results: for each res_id, the generated template values for
+                              subject, body, email_from and reply_to
+        """
+        subjects = self.render_template_batch(cr, uid, wizard.subject, wizard.model, res_ids, context=context)
+        bodies = self.render_template_batch(cr, uid, wizard.body, wizard.model, res_ids, context=context, post_process=True)
+        emails_from = self.render_template_batch(cr, uid, wizard.email_from, wizard.model, res_ids, context=context)
+        replies_to = self.render_template_batch(cr, uid, wizard.reply_to, wizard.model, res_ids, context=context)
+
+        ctx = dict(context, thread_model=wizard.model)
+        default_recipients = self.pool['mail.thread'].message_get_default_recipients(cr, uid, res_ids, context=ctx)
+
+        results = dict.fromkeys(res_ids, False)
+        for res_id in res_ids:
+            results[res_id] = {
+                'subject': subjects[res_id],
+                'body': bodies[res_id],
+                'email_from': emails_from[res_id],
+                'reply_to': replies_to[res_id],
+            }
+            results[res_id].update(default_recipients.get(res_id, dict()))
+        return results
+
+    def render_template_batch(self, cr, uid, template, model, res_ids, context=None, post_process=False):
         """ Render the given template text, replace mako-like expressions ``${expr}``
         """ Render the given template text, replace mako-like expressions ``${expr}``
-            with the result of evaluating these expressions with an evaluation context
-            containing:
+        with the result of evaluating these expressions with an evaluation context
+        containing:
 
 
-                * ``user``: browse_record of the current user
-                * ``object``: browse_record of the document record this mail is
-                              related to
-                * ``context``: the context passed to the mail composition wizard
+            * ``user``: browse_record of the current user
+            * ``object``: browse_record of the document record this mail is
+                          related to
+            * ``context``: the context passed to the mail composition wizard
 
 
-            :param str template: the template text to render
-            :param str model: model name of the document record this mail is related to.
-            :param int res_id: id of the document record this mail is related to.
+        :param str template: the template text to render
+        :param str model: model name of the document record this mail is related to
+        :param list res_ids: list of record ids
         """
         if context is None:
             context = {}
         """
         if context is None:
             context = {}
-
-        def merge(match):
-            exp = str(match.group()[2:-1]).strip()
-            result = eval(exp, {
-                'user': self.pool.get('res.users').browse(cr, uid, uid, context=context),
-                'object': self.pool.get(model).browse(cr, uid, res_id, context=context),
-                'context': dict(context),  # copy context to prevent side-effects of eval
+        results = dict.fromkeys(res_ids, False)
+
+        for res_id in res_ids:
+            def merge(match):
+                exp = str(match.group()[2:-1]).strip()
+                result = eval(exp, {
+                    'user': self.pool.get('res.users').browse(cr, uid, uid, context=context),
+                    'object': self.pool[model].browse(cr, uid, res_id, context=context),
+                    'context': dict(context),  # copy context to prevent side-effects of eval
                 })
                 })
-            return result and tools.ustr(result) or ''
-        return template and EXPRESSION_PATTERN.sub(merge, template)
+                return result and tools.ustr(result) or ''
+            results[res_id] = template and EXPRESSION_PATTERN.sub(merge, template)
+        return results
+
+    # Compatibility methods
+    def render_template(self, cr, uid, template, model, res_id, context=None):
+        return self.render_template_batch(cr, uid, template, model, [res_id], context)[res_id]
+
+    def render_message(self, cr, uid, wizard, res_id, context=None):
+        return self.render_message_batch(cr, uid, wizard, [res_id], context)[res_id]