diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/wizard | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/wizard')
| -rw-r--r-- | addons/mail/wizard/__init__.py | 11 | ||||
| -rw-r--r-- | addons/mail/wizard/base_module_uninstall.py | 13 | ||||
| -rw-r--r-- | addons/mail/wizard/base_partner_merge.py | 12 | ||||
| -rw-r--r-- | addons/mail/wizard/invite.py | 97 | ||||
| -rw-r--r-- | addons/mail/wizard/invite_view.xml | 35 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_blacklist_remove.py | 14 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_blacklist_remove_view.xml | 19 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_compose_message.py | 535 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_compose_message_view.xml | 97 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_resend_cancel.py | 37 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_resend_cancel_views.xml | 27 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_resend_message.py | 98 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_resend_message_views.xml | 43 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_template_preview.py | 86 | ||||
| -rw-r--r-- | addons/mail/wizard/mail_template_preview_views.xml | 61 |
15 files changed, 1185 insertions, 0 deletions
diff --git a/addons/mail/wizard/__init__.py b/addons/mail/wizard/__init__.py new file mode 100644 index 00000000..9adf18a6 --- /dev/null +++ b/addons/mail/wizard/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import invite +from . import mail_blacklist_remove +from . import mail_compose_message +from . import mail_resend_cancel +from . import mail_resend_message +from . import mail_template_preview +from . import base_module_uninstall +from . import base_partner_merge diff --git a/addons/mail/wizard/base_module_uninstall.py b/addons/mail/wizard/base_module_uninstall.py new file mode 100644 index 00000000..43e029ba --- /dev/null +++ b/addons/mail/wizard/base_module_uninstall.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class BaseModuleUninstall(models.TransientModel): + _inherit = "base.module.uninstall" + + def _get_models(self): + # consider mail-thread models only + models = super(BaseModuleUninstall, self)._get_models() + return models.filtered('is_mail_thread') diff --git a/addons/mail/wizard/base_partner_merge.py b/addons/mail/wizard/base_partner_merge.py new file mode 100644 index 00000000..aed2cefd --- /dev/null +++ b/addons/mail/wizard/base_partner_merge.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import api, models, _ + + +class MergePartnerAutomatic(models.TransientModel): + + _inherit = 'base.partner.merge.automatic.wizard' + + def _log_merge_operation(self, src_partners, dst_partner): + super(MergePartnerAutomatic, self)._log_merge_operation(src_partners, dst_partner) + dst_partner.message_post(body='%s %s' % (_("Merged with the following partners:"), ", ".join('%s <%s> (ID %s)' % (p.name, p.email or 'n/a', p.id) for p in src_partners))) diff --git a/addons/mail/wizard/invite.py b/addons/mail/wizard/invite.py new file mode 100644 index 00000000..8b6a2de8 --- /dev/null +++ b/addons/mail/wizard/invite.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from lxml import etree +from lxml.html import builder as html + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class Invite(models.TransientModel): + """ Wizard to invite partners (or channels) and make them followers. """ + _name = 'mail.wizard.invite' + _description = 'Invite wizard' + + @api.model + def default_get(self, fields): + result = super(Invite, self).default_get(fields) + if self._context.get('mail_invite_follower_channel_only'): + result['send_mail'] = False + if 'message' not in fields: + return result + + user_name = self.env.user.display_name + model = result.get('res_model') + res_id = result.get('res_id') + if model and res_id: + document = self.env['ir.model']._get(model).display_name + title = self.env[model].browse(res_id).display_name + msg_fmt = _('%(user_name)s invited you to follow %(document)s document: %(title)s') + else: + msg_fmt = _('%(user_name)s invited you to follow a new document.') + + text = msg_fmt % locals() + message = html.DIV( + html.P(_('Hello,')), + html.P(text) + ) + result['message'] = etree.tostring(message) + return result + + res_model = fields.Char('Related Document Model', required=True, index=True, help='Model of the followed resource') + res_id = fields.Integer('Related Document ID', index=True, help='Id of the followed resource') + partner_ids = fields.Many2many('res.partner', string='Recipients', help="List of partners that will be added as follower of the current document.", + domain=[('type', '!=', 'private')]) + channel_ids = fields.Many2many('mail.channel', string='Channels', help='List of channels that will be added as listeners of the current document.', + domain=[('channel_type', '=', 'channel')]) + message = fields.Html('Message') + send_mail = fields.Boolean('Send Email', default=True, help="If checked, the partners will receive an email warning they have been added in the document's followers.") + + def add_followers(self): + if not self.env.user.email: + raise UserError(_("Unable to post message, please configure the sender's email address.")) + email_from = self.env.user.email_formatted + for wizard in self: + Model = self.env[wizard.res_model] + document = Model.browse(wizard.res_id) + + # filter partner_ids to get the new followers, to avoid sending email to already following partners + new_partners = wizard.partner_ids - document.sudo().message_partner_ids + new_channels = wizard.channel_ids - document.message_channel_ids + document.message_subscribe(new_partners.ids, new_channels.ids) + + model_name = self.env['ir.model']._get(wizard.res_model).display_name + # send an email if option checked and if a message exists (do not send void emails) + if wizard.send_mail and wizard.message and not wizard.message == '<br>': # when deleting the message, cleditor keeps a <br> + message = self.env['mail.message'].create({ + 'subject': _('Invitation to follow %(document_model)s: %(document_name)s', document_model=model_name, document_name=document.display_name), + 'body': wizard.message, + 'record_name': document.display_name, + 'email_from': email_from, + 'reply_to': email_from, + 'model': wizard.res_model, + 'res_id': wizard.res_id, + 'no_auto_thread': True, + 'add_sign': True, + }) + partners_data = [] + recipient_data = self.env['mail.followers']._get_recipient_data(document, 'comment', False, pids=new_partners.ids) + for pid, cid, active, pshare, ctype, notif, groups in recipient_data: + pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []} + if not pshare and notif: # has an user and is not shared, is therefore user + partners_data.append(dict(pdata, type='user')) + elif pshare and notif: # has an user and is shared, is therefore portal + partners_data.append(dict(pdata, type='portal')) + else: # has no user, is therefore customer + partners_data.append(dict(pdata, type='customer')) + + document._notify_record_by_email(message, {'partners': partners_data, 'channels': []}, send_after_commit=False) + # in case of failure, the web client must know the message was + # deleted to discard the related failure notification + self.env['bus.bus'].sendone( + (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), + {'type': 'deletion', 'message_ids': message.ids} + ) + message.unlink() + return {'type': 'ir.actions.act_window_close'} diff --git a/addons/mail/wizard/invite_view.xml b/addons/mail/wizard/invite_view.xml new file mode 100644 index 00000000..aa8633d2 --- /dev/null +++ b/addons/mail/wizard/invite_view.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <!-- wizard view --> + <record model="ir.ui.view" id="mail_wizard_invite_form"> + <field name="name">Add Followers</field> + <field name="model">mail.wizard.invite</field> + <field name="arch" type="xml"> + <form string="Add Followers"> + <group> + <field name="res_model" invisible="1"/> + <field name="res_id" invisible="1"/> + <field name="partner_ids" widget="many2many_tags_email" + placeholder="Add contacts to notify..." + context="{'force_email':True, 'show_email':True}" + invisible="context.get('mail_invite_follower_channel_only')"/> + <field name="channel_ids" widget="many2many_tags" + placeholder="Add channels to notify..." + invisible="not context.get('mail_invite_follower_channel_only')" + options="{'no_create': True}"/> + <field name="send_mail" invisible="context.get('mail_invite_follower_channel_only')"/> + <field name="message" attrs="{'invisible': [('send_mail','!=',True)]}" options="{'style-inline': true, 'no-attachment': true}" class="test_message"/> + </group> + <footer> + <button string="Add Followers" + name="add_followers" type="object" class="btn-primary"/> + <button string="Cancel" class="btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> + + </data> +</odoo> diff --git a/addons/mail/wizard/mail_blacklist_remove.py b/addons/mail/wizard/mail_blacklist_remove.py new file mode 100644 index 00000000..2fc6374c --- /dev/null +++ b/addons/mail/wizard/mail_blacklist_remove.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class MailBlacklistRemove(models.TransientModel): + _name = 'mail.blacklist.remove' + _description = 'Remove email from blacklist wizard' + + email = fields.Char(name="Email", readonly=True, required=True) + reason = fields.Char(name="Reason") + + def action_unblacklist_apply(self): + return self.env['mail.blacklist'].action_remove_with_reason(self.email, self.reason) diff --git a/addons/mail/wizard/mail_blacklist_remove_view.xml b/addons/mail/wizard/mail_blacklist_remove_view.xml new file mode 100644 index 00000000..4bc8edd4 --- /dev/null +++ b/addons/mail/wizard/mail_blacklist_remove_view.xml @@ -0,0 +1,19 @@ +<?xml version="1.0"?> +<odoo> + <record id="mail_blacklist_remove_view_form" model="ir.ui.view"> + <field name="name">mail.blacklist.remove.form</field> + <field name="model">mail.blacklist.remove</field> + <field name="arch" type="xml"> + <form string="mail_blacklist_removal"> + <group class="oe_title"> + <field name="email" string="Email Address"/> + <field name="reason" string="Reason"/> + </group> + <footer> + <button name="action_unblacklist_apply" string="Confirm" type="object" class="btn-primary"/> + <button string="Discard" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> +</odoo> diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py new file mode 100644 index 00000000..327cad2b --- /dev/null +++ b/addons/mail/wizard/mail_compose_message.py @@ -0,0 +1,535 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import ast +import base64 +import re + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError + + +# main mako-like expression pattern +EXPRESSION_PATTERN = re.compile('(\$\{.+?\})') + + +def _reopen(self, res_id, model, context=None): + # save original model in context, because selecting the list of available + # templates requires a model in context + context = dict(context or {}, default_model=model) + return {'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_id': res_id, + 'res_model': self._name, + 'target': 'new', + 'context': context, + } + + +class MailComposer(models.TransientModel): + """ Generic message composition wizard. You may inherit from this wizard + at model and view levels to provide specific features. + + The behavior of the wizard depends on the composition_mode field: + - '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. + """ + _name = 'mail.compose.message' + _description = 'Email composition wizard' + _log_access = True + _batch_size = 500 + + @api.model + def default_get(self, fields): + """ Handle composition mode. Some details about context keys: + - comment: default mode, model and ID of a record the user comments + - default_model or active_model + - default_res_id or active_id + - mass_mail: model and IDs of records the user mass-mails + - active_ids: record IDs + - default_model or active_model + """ + result = super(MailComposer, self).default_get(fields) + + # author + missing_author = 'author_id' in fields and 'author_id' not in result + missing_email_from = 'email_from' in fields and 'email_from' not in result + if missing_author or missing_email_from: + author_id, email_from = self.env['mail.thread']._message_compute_author(result.get('author_id'), result.get('email_from'), raise_exception=False) + if missing_email_from: + result['email_from'] = email_from + if missing_author: + result['author_id'] = author_id + + if 'model' in fields and 'model' not in result: + result['model'] = self._context.get('active_model') + if 'res_id' in fields and 'res_id' not in result: + result['res_id'] = self._context.get('active_id') + if 'no_auto_thread' in fields and 'no_auto_thread' not in result and result.get('model'): + # doesn't support threading + if result['model'] not in self.env or not hasattr(self.env[result['model']], 'message_post'): + result['no_auto_thread'] = True + + if 'active_domain' in self._context: # not context.get() because we want to keep global [] domains + result['active_domain'] = '%s' % self._context.get('active_domain') + if result.get('composition_mode') == 'comment' and (set(fields) & set(['model', 'res_id', 'partner_ids', 'record_name', 'subject'])): + result.update(self.get_record_data(result)) + + filtered_result = dict((fname, result[fname]) for fname in result if fname in fields) + return filtered_result + + # content + subject = fields.Char('Subject') + body = fields.Html('Contents', default='', sanitize_style=True) + parent_id = fields.Many2one( + 'mail.message', 'Parent Message', index=True, ondelete='set null', + help="Initial thread message.") + template_id = fields.Many2one( + 'mail.template', 'Use template', index=True, + domain="[('model', '=', model)]") + attachment_ids = fields.Many2many( + 'ir.attachment', 'mail_compose_message_ir_attachments_rel', + 'wizard_id', 'attachment_id', 'Attachments') + layout = fields.Char('Layout', copy=False) # xml id of layout + add_sign = fields.Boolean(default=True) + # origin + email_from = fields.Char('From', help="Email address of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter.") + author_id = fields.Many2one( + 'res.partner', 'Author', index=True, + help="Author of the message. If not set, email_from may hold an email address that did not match any partner.") + # composition + composition_mode = fields.Selection(selection=[ + ('comment', 'Post on a document'), + ('mass_mail', 'Email Mass Mailing'), + ('mass_post', 'Post on Multiple Documents')], string='Composition mode', default='comment') + model = fields.Char('Related Document Model', index=True) + res_id = fields.Integer('Related Document ID', index=True) + record_name = fields.Char('Message Record Name', help="Name get of the related document.") + use_active_domain = fields.Boolean('Use active domain') + active_domain = fields.Text('Active domain', readonly=True) + # characteristics + message_type = fields.Selection([ + ('comment', 'Comment'), + ('notification', 'System notification')], + 'Type', required=True, default='comment', + help="Message type: email for email message, notification for system " + "message, comment for other messages such as user replies") + subtype_id = fields.Many2one( + 'mail.message.subtype', 'Subtype', ondelete='set null', index=True, + default=lambda self: self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')) + mail_activity_type_id = fields.Many2one( + 'mail.activity.type', 'Mail Activity Type', + index=True, ondelete='set null') + # destination + reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.') + no_auto_thread = fields.Boolean( + 'No threading for answers', + help='Answers do not go in the original document discussion thread. This has an impact on the generated message-id.') + is_log = fields.Boolean('Log an Internal Note', + help='Whether the message is an internal note (comment mode only)') + partner_ids = fields.Many2many( + 'res.partner', 'mail_compose_message_res_partner_rel', + 'wizard_id', 'partner_id', 'Additional Contacts', + domain=[('type', '!=', 'private')]) + # mass mode options + notify = fields.Boolean('Notify followers', help='Notify followers of the document (mass post only)') + auto_delete = fields.Boolean('Delete Emails', + help='This option permanently removes any track of email after it\'s been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.') + auto_delete_message = fields.Boolean('Delete Message Copy', help='Do not keep a copy of the email in the document communication history (mass mailing only)') + mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') + + @api.model + def get_record_data(self, values): + """ Returns a defaults-like dict with initial values for the composition + 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. """ + result, subject = {}, False + if values.get('parent_id'): + parent = self.env['mail.message'].browse(values.get('parent_id')) + 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()) + parent.partner_ids.ids + result['partner_ids'] = partner_ids + elif values.get('model') and values.get('res_id'): + doc_name_get = self.env[values.get('model')].browse(values.get('res_id')).name_get() + result['record_name'] = doc_name_get and doc_name_get[0][1] or '' + subject = tools.ustr(result['record_name']) + + re_prefix = _('Re:') + if subject and not (subject.startswith('Re:') or subject.startswith(re_prefix)): + subject = "%s %s" % (re_prefix, subject) + result['subject'] = subject + + return result + + # ------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------ + # action buttons call with positionnal arguments only, so we need an intermediary function + # to ensure the context is passed correctly + def action_send_mail(self): + self.send_mail() + return {'type': 'ir.actions.act_window_close'} + + def send_mail(self, auto_commit=False): + """ Process the wizard content and proceed with sending the related + email(s), rendering any template patterns on the fly if needed. """ + notif_layout = self._context.get('custom_layout') + # Several custom layouts make use of the model description at rendering, e.g. in the + # 'View <document>' button. Some models are used for different business concepts, such as + # 'purchase.order' which is used for a RFQ and and PO. To avoid confusion, we must use a + # different wording depending on the state of the object. + # Therefore, we can set the description in the context from the beginning to avoid falling + # back on the regular display_name retrieved in '_notify_prepare_template_context'. + model_description = self._context.get('model_description') + for wizard in self: + # Duplicate attachments linked to the email.template. + # Indeed, basic mail.compose.message wizard duplicates attachments in mass + # mailing mode. But in 'single post' mode, attachments of an email template + # also have to be duplicated to avoid changing their ownership. + if wizard.attachment_ids and wizard.composition_mode != 'mass_mail' and wizard.template_id: + new_attachment_ids = [] + for attachment in wizard.attachment_ids: + if attachment in wizard.template_id.attachment_ids: + new_attachment_ids.append(attachment.copy({'res_model': 'mail.compose.message', 'res_id': wizard.id}).id) + else: + new_attachment_ids.append(attachment.id) + new_attachment_ids.reverse() + wizard.write({'attachment_ids': [(6, 0, new_attachment_ids)]}) + + # Mass Mailing + mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post') + + ActiveModel = self.env[wizard.model] if wizard.model and hasattr(self.env[wizard.model], 'message_post') else self.env['mail.thread'] + if wizard.composition_mode == 'mass_post': + # do not send emails directly but use the queue instead + # add context key to avoid subscribing the author + ActiveModel = ActiveModel.with_context(mail_notify_force_send=False, mail_create_nosubscribe=True) + # 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.env[wizard.model].search(ast.literal_eval(wizard.active_domain)).ids + elif mass_mode and wizard.model and self._context.get('active_ids'): + res_ids = self._context['active_ids'] + else: + res_ids = [wizard.res_id] + + batch_size = int(self.env['ir.config_parameter'].sudo().get_param('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)] + + if wizard.composition_mode == 'mass_mail' or wizard.is_log or (wizard.composition_mode == 'mass_post' and not wizard.notify): # log a note: subtype is False + subtype_id = False + elif wizard.subtype_id: + subtype_id = wizard.subtype_id.id + else: + subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment') + + for res_ids in sliced_res_ids: + # mass mail mode: mail are sudo-ed, as when going through get_mail_values + # standard access rights on related records will be checked when browsing them + # to compute mail values. If people have access to the records they have rights + # to create lots of emails in sudo as it is consdiered as a technical model. + batch_mails_sudo = self.env['mail.mail'].sudo() + all_mail_values = wizard.get_mail_values(res_ids) + for res_id, mail_values in all_mail_values.items(): + if wizard.composition_mode == 'mass_mail': + batch_mails_sudo |= self.env['mail.mail'].sudo().create(mail_values) + else: + post_params = dict( + message_type=wizard.message_type, + subtype_id=subtype_id, + email_layout_xmlid=notif_layout, + add_sign=not bool(wizard.template_id), + mail_auto_delete=wizard.template_id.auto_delete if wizard.template_id else self._context.get('mail_auto_delete', True), + model_description=model_description) + post_params.update(mail_values) + if ActiveModel._name == 'mail.thread': + if wizard.model: + post_params['model'] = wizard.model + post_params['res_id'] = res_id + if not ActiveModel.message_notify(**post_params): + # if message_notify returns an empty record set, no recipients where found. + raise UserError(_("No recipient found.")) + else: + ActiveModel.browse(res_id).message_post(**post_params) + + if wizard.composition_mode == 'mass_mail': + batch_mails_sudo.send(auto_commit=auto_commit) + + def get_mail_values(self, res_ids): + """Generate the values that will be used by send_mail to create mail_messages + or mail_mails. """ + self.ensure_one() + results = dict.fromkeys(res_ids, False) + rendered_values = {} + mass_mail_mode = self.composition_mode == 'mass_mail' + + # render all template-based value at once + if mass_mail_mode and self.model: + rendered_values = self.render_message(res_ids) + # compute alias-based reply-to in batch + reply_to_value = dict.fromkeys(res_ids, None) + if mass_mail_mode and not self.no_auto_thread: + records = self.env[self.model].browse(res_ids) + reply_to_value = records._notify_get_reply_to(default=self.email_from) + + blacklisted_rec_ids = set() + if mass_mail_mode and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']): + self.env['mail.blacklist'].flush(['email']) + self._cr.execute("SELECT email FROM mail_blacklist WHERE active=true") + blacklist = {x[0] for x in self._cr.fetchall()} + if blacklist: + targets = self.env[self.model].browse(res_ids).read(['email_normalized']) + # First extract email from recipient before comparing with blacklist + blacklisted_rec_ids.update(target['id'] for target in targets + if target['email_normalized'] in blacklist) + + for res_id in res_ids: + # static wizard (mail.message) values + mail_values = { + 'subject': self.subject, + 'body': self.body or '', + 'parent_id': self.parent_id and self.parent_id.id, + 'partner_ids': [partner.id for partner in self.partner_ids], + 'attachment_ids': [attach.id for attach in self.attachment_ids], + 'author_id': self.author_id.id, + 'email_from': self.email_from, + 'record_name': self.record_name, + 'no_auto_thread': self.no_auto_thread, + 'mail_server_id': self.mail_server_id.id, + 'mail_activity_type_id': self.mail_activity_type_id.id, + } + + # mass mailing: rendering override wizard static values + if mass_mail_mode and self.model: + record = self.env[self.model].browse(res_id) + mail_values['headers'] = record._notify_email_headers() + # keep a copy unless specifically requested, reset record name (avoid browsing records) + mail_values.update(notification=not self.auto_delete_message, model=self.model, res_id=res_id, record_name=False) + # auto deletion of mail_mail + if self.auto_delete or self.template_id.auto_delete: + mail_values['auto_delete'] = True + # 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 self.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 self.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.env['ir.attachment'].browse(attach_id).copy({'res_model': self._name, 'res_id': self.id}) + attachment_ids.append(new_attach_id.id) + attachment_ids.reverse() + mail_values['attachment_ids'] = self.env['mail.thread'].with_context(attached_to=record)._message_post_process_attachments( + mail_values.pop('attachments', []), + attachment_ids, + {'model': 'mail.message', 'res_id': 0} + )['attachment_ids'] + # Filter out the blacklisted records by setting the mail state to cancel -> Used for Mass Mailing stats + if res_id in blacklisted_rec_ids: + mail_values['state'] = 'cancel' + # Do not post the mail into the recipient's chatter + mail_values['notification'] = False + + results[res_id] = mail_values + return results + + # ------------------------------------------------------------ + # TEMPLATES + # ------------------------------------------------------------ + + @api.onchange('template_id') + def onchange_template_id_wrapper(self): + self.ensure_one() + values = self.onchange_template_id(self.template_id.id, self.composition_mode, self.model, self.res_id)['value'] + for fname, value in values.items(): + setattr(self, fname, value) + + def onchange_template_id(self, template_id, composition_mode, model, res_id): + """ - mass_mailing: we cannot render, so return the template values + - normal mode: return rendered values + /!\ for x2many field, this onchange return command instead of ids + """ + if template_id and composition_mode == 'mass_mail': + template = self.env['mail.template'].browse(template_id) + fields = ['subject', 'body_html', 'email_from', 'reply_to', 'mail_server_id'] + values = dict((field, getattr(template, field)) for field in fields if getattr(template, field)) + if template.attachment_ids: + values['attachment_ids'] = [att.id for att in template.attachment_ids] + if template.mail_server_id: + values['mail_server_id'] = template.mail_server_id.id + elif template_id: + values = self.generate_email_for_composer( + template_id, [res_id], + ['subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'attachment_ids', 'mail_server_id'] + )[res_id] + # transform attachments into attachment_ids; not attached to the document because this will + # be done further in the posting process, allowing to clean database if email not send + attachment_ids = [] + Attachment = self.env['ir.attachment'] + for attach_fname, attach_datas in values.pop('attachments', []): + data_attach = { + 'name': attach_fname, + 'datas': attach_datas, + 'res_model': 'mail.compose.message', + 'res_id': 0, + 'type': 'binary', # override default_type from context, possibly meant for another model! + } + attachment_ids.append(Attachment.create(data_attach).id) + if values.get('attachment_ids', []) or attachment_ids: + values['attachment_ids'] = [(6, 0, values.get('attachment_ids', []) + attachment_ids)] + else: + default_values = self.with_context(default_composition_mode=composition_mode, default_model=model, default_res_id=res_id).default_get(['composition_mode', 'model', 'res_id', 'parent_id', 'partner_ids', 'subject', 'body', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id']) + values = dict((key, default_values[key]) for key in ['subject', 'body', 'partner_ids', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id'] if key in default_values) + + if values.get('body_html'): + values['body'] = values.pop('body_html') + + # This onchange should return command instead of ids for x2many field. + values = self._convert_to_write(values) + + return {'value': values} + + def save_as_template(self): + """ hit save as template button: current form value will be a new + template attached to the current document. """ + for record in self: + model = self.env['ir.model']._get(record.model or 'mail.message') + model_name = model.name or '' + template_name = "%s: %s" % (model_name, tools.ustr(record.subject)) + values = { + 'name': template_name, + 'subject': record.subject or False, + 'body_html': record.body or False, + 'model_id': model.id or False, + 'attachment_ids': [(6, 0, [att.id for att in record.attachment_ids])], + } + template = self.env['mail.template'].create(values) + # generate the saved template + record.write({'template_id': template.id}) + record.onchange_template_id_wrapper() + return _reopen(self, record.id, record.model, context=self._context) + + # ------------------------------------------------------------ + # RENDERING + # ------------------------------------------------------------ + + def render_message(self, res_ids): + """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 + """ + self.ensure_one() + multi_mode = True + if isinstance(res_ids, int): + multi_mode = False + res_ids = [res_ids] + + subjects = self.env['mail.render.mixin']._render_template(self.subject, self.model, res_ids) + bodies = self.env['mail.render.mixin']._render_template(self.body, self.model, res_ids, post_process=True) + emails_from = self.env['mail.render.mixin']._render_template(self.email_from, self.model, res_ids) + replies_to = self.env['mail.render.mixin']._render_template(self.reply_to, self.model, res_ids) + default_recipients = {} + if not self.partner_ids: + records = self.env[self.model].browse(res_ids).sudo() + default_recipients = records._message_get_default_recipients() + + 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())) + + # generate template-based values + if self.template_id: + template_values = self.generate_email_for_composer( + self.template_id.id, res_ids, + ['email_to', 'partner_to', 'email_cc', 'attachment_ids', 'mail_server_id']) + else: + template_values = {} + + for res_id in res_ids: + if template_values.get(res_id): + # recipients are managed by the template + results[res_id].pop('partner_ids', None) + results[res_id].pop('email_to', None) + results[res_id].pop('email_cc', None) + # remove attachments from template values as they should not be rendered + template_values[res_id].pop('attachment_ids', None) + else: + template_values[res_id] = dict() + # update template values by composer values + template_values[res_id].update(results[res_id]) + + return multi_mode and template_values or template_values[res_ids[0]] + + @api.model + def generate_email_for_composer(self, template_id, res_ids, fields): + """ Call email_template.generate_email(), get fields relevant for + mail.compose.message, transform email_cc and email_to into partner_ids """ + multi_mode = True + if isinstance(res_ids, int): + multi_mode = False + res_ids = [res_ids] + + returned_fields = fields + ['partner_ids', 'attachments'] + values = dict.fromkeys(res_ids, False) + + template_values = self.env['mail.template'].with_context(tpl_partners_only=True).browse(template_id).generate_email(res_ids, fields) + for res_id in res_ids: + res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field)) + res_id_values['body'] = res_id_values.pop('body_html', '') + values[res_id] = res_id_values + + return multi_mode and values or values[res_ids[0]] + + @api.autovacuum + def _gc_lost_attachments(self): + """ Garbage collect lost mail attachments. Those are attachments + - linked to res_model 'mail.compose.message', the composer wizard + - with res_id 0, because they were created outside of an existing + wizard (typically user input through Chatter or reports + created on-the-fly by the templates) + - unused since at least one day (create_date and write_date) + """ + limit_date = fields.Datetime.subtract(fields.Datetime.now(), days=1) + self.env['ir.attachment'].search([ + ('res_model', '=', self._name), + ('res_id', '=', 0), + ('create_date', '<', limit_date), + ('write_date', '<', limit_date)] + ).unlink() diff --git a/addons/mail/wizard/mail_compose_message_view.xml b/addons/mail/wizard/mail_compose_message_view.xml new file mode 100644 index 00000000..1e9b0a69 --- /dev/null +++ b/addons/mail/wizard/mail_compose_message_view.xml @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record model="ir.ui.view" id="email_compose_message_wizard_form"> + <field name="name">mail.compose.message.form</field> + <field name="model">mail.compose.message</field> + <field name="groups_id" eval="[(4,ref('base.group_user'))]"/> + <field name="arch" type="xml"> + <form string="Compose Email"> + <group> + <!-- truly invisible fields for control and options --> + <field name="composition_mode" invisible="1"/> + <field name="model" invisible="1"/> + <field name="res_id" invisible="1"/> + <field name="is_log" invisible="1"/> + <field name="parent_id" invisible="1"/> + <field name="mail_server_id" invisible="1"/> + <field name="active_domain" invisible="1"/> + + <!-- Various warnings --> + <div colspan="2" class="oe_form_box_info bg-info oe_text_center" + attrs="{'invisible': [('active_domain', '=', False)]}"> + <p attrs="{'invisible': [('use_active_domain', '=', False)]}"> + <strong> + All records matching your current search filter will be mailed, + not only the ids selected in the list view. + </strong><br /> + The email will be sent for all the records selected in the list.<br /> + Confirming this wizard will probably take a few minutes blocking your browser. + </p> + <p attrs="{'invisible': [('use_active_domain', '=', True)]}"> + <strong>Only records checked in list view will be used.</strong><br /> + The email will be sent for all the records selected in the list. + </p> + <p class="mt8"> + <span attrs="{'invisible': [('use_active_domain', '=', True)]}"> + If you want to send it for all the records matching your search criterion, check this box : + </span> + <span attrs="{'invisible': [('use_active_domain', '=', False)]}"> + If you want to use only selected records please uncheck this selection box : + </span> + <field class="oe_inline" name="use_active_domain"/> + </p> + </div> + <!-- visible wizard --> + <field name="email_from" + attrs="{'invisible':[('composition_mode', '!=', 'mass_mail')]}"/> + <label for="partner_ids" string="Recipients" attrs="{'invisible': [('is_log', '=', True)]}" groups="base.group_user"/> + <div groups="base.group_user" attrs="{'invisible': [('is_log', '=', True)]}"> + <span attrs="{'invisible': [('composition_mode', '!=', 'mass_mail')]}"> + <strong>Email mass mailing</strong> on + <span attrs="{'invisible': [('use_active_domain', '=', True)]}">the selected records</span> + <span attrs="{'invisible': [('use_active_domain', '=', False)]}">the current search filter</span>. + </span> + <span name="document_followers_text" attrs="{'invisible':['|', ('model', '=', False), ('composition_mode', '=', 'mass_mail')]}">Followers of the document and</span> + <field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to notify..." + context="{'force_email':True, 'show_email':True}" + attrs="{'invisible': [('composition_mode', '!=', 'comment')]}"/> + </div> + <field name="subject" placeholder="Subject..." required="True"/> + <!-- mass post --> + <field name="notify" + attrs="{'invisible':[('composition_mode', '!=', 'mass_post')]}"/> + <!-- mass mailing --> + <field name="no_auto_thread" attrs="{'invisible':[('composition_mode', '!=', 'mass_mail')]}"/> + <field name="reply_to" placeholder="Email address to redirect replies..." + attrs="{'invisible':['|', ('no_auto_thread', '=', False), ('composition_mode', '!=', 'mass_mail')], + 'required':[('no_auto_thread', '=', True), ('composition_mode', '=', 'mass_mail')]}"/> + </group> + <field name="body" options="{'style-inline': true}"/> + <group col="4"> + <field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2"/> + <field name="template_id" options="{'no_create': True}" + context="{'default_model': model, 'default_body_html': body, 'default_subject': subject}"/> + </group> + <footer> + <button string="Send" attrs="{'invisible': [('is_log', '=', True)]}" name="action_send_mail" type="object" class="btn-primary o_mail_send"/> + <button string="Log" attrs="{'invisible': [('is_log', '=', False)]}" name="action_send_mail" type="object" class="btn-primary"/> + <button string="Cancel" class="btn-secondary" special="cancel" /> + + <button icon="fa-lg fa-save" type="object" name="save_as_template" string="Save as new template" + class="float-right btn-secondary" help="Save as a new template"/> + </footer> + </form> + </field> + </record> + + <record id="action_email_compose_message_wizard" model="ir.actions.act_window"> + <field name="name">Compose Email</field> + <field name="res_model">mail.compose.message</field> + <field name="binding_model_id" ref="mail.model_mail_compose_message"/> + <field name="type">ir.actions.act_window</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + </data> +</odoo> diff --git a/addons/mail/wizard/mail_resend_cancel.py b/addons/mail/wizard/mail_resend_cancel.py new file mode 100644 index 00000000..dfeef296 --- /dev/null +++ b/addons/mail/wizard/mail_resend_cancel.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models + + +class MailResendCancel(models.TransientModel): + _name = 'mail.resend.cancel' + _description = 'Dismiss notification for resend by model' + + model = fields.Char(string='Model') + help_message = fields.Char(string='Help message', compute='_compute_help_message') + + @api.depends('model') + def _compute_help_message(self): + for wizard in self: + wizard.help_message = _("Are you sure you want to discard %s mail delivery failures? You won't be able to re-send these mails later!") % (wizard._context.get('unread_counter')) + + def cancel_resend_action(self): + author_id = self.env.user.partner_id.id + for wizard in self: + self._cr.execute(""" + SELECT notif.id, mes.id + FROM mail_message_res_partner_needaction_rel notif + JOIN mail_message mes + ON notif.mail_message_id = mes.id + WHERE notif.notification_type = 'email' AND notif.notification_status IN ('bounce', 'exception') + AND mes.model = %s + AND mes.author_id = %s + """, (wizard.model, author_id)) + res = self._cr.fetchall() + notif_ids = [row[0] for row in res] + messages_ids = list(set([row[1] for row in res])) + if notif_ids: + self.env["mail.notification"].browse(notif_ids).sudo().write({'notification_status': 'canceled'}) + self.env["mail.message"].browse(messages_ids)._notify_message_notification_update() + return {'type': 'ir.actions.act_window_close'} diff --git a/addons/mail/wizard/mail_resend_cancel_views.xml b/addons/mail/wizard/mail_resend_cancel_views.xml new file mode 100644 index 00000000..c8cc6632 --- /dev/null +++ b/addons/mail/wizard/mail_resend_cancel_views.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo><data> + <record id="mail_resend_cancel_view_form" model="ir.ui.view"> + <field name="name">mail.resend.cancel.view.form</field> + <field name="model">mail.resend.cancel</field> + <field name="groups_id" eval="[(4,ref('base.group_user'))]"/> + <field name="arch" type="xml"> + <form string="Cancel notification in failure"> + <field name="model" invisible='1'/> + <field name="help_message"/> + <p>If you want to re-send them, click Cancel now, then click on the notification and review them one by one by clicking on the red envelope next to each message.</p> + <img src="/mail/static/img/red_envelope.png" alt="Envelope Example"/> + <footer> + <button string="Discard delivery failures" name="cancel_resend_action" type="object" class="btn-primary" /> + <button string="Cancel" class="btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> + <record id="mail_resend_cancel_action" model="ir.actions.act_window"> + <field name="name">Discard mail delivery failures</field> + <field name="res_model">mail.resend.cancel</field> + <field name="type">ir.actions.act_window</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> +</data></odoo> diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py new file mode 100644 index 00000000..b1c6304b --- /dev/null +++ b/addons/mail/wizard/mail_resend_message.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class MailResendMessage(models.TransientModel): + _name = 'mail.resend.message' + _description = 'Email resend wizard' + + mail_message_id = fields.Many2one('mail.message', 'Message', readonly=True) + partner_ids = fields.One2many('mail.resend.partner', 'resend_wizard_id', string='Recipients') + notification_ids = fields.Many2many('mail.notification', string='Notifications', readonly=True) + has_cancel = fields.Boolean(compute='_compute_has_cancel') + partner_readonly = fields.Boolean(compute='_compute_partner_readonly') + + @api.depends("partner_ids") + def _compute_has_cancel(self): + self.has_cancel = self.partner_ids.filtered(lambda p: not p.resend) + + def _compute_partner_readonly(self): + self.partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False) + + @api.model + def default_get(self, fields): + rec = super(MailResendMessage, self).default_get(fields) + message_id = self._context.get('mail_message_to_resend') + if message_id: + mail_message_id = self.env['mail.message'].browse(message_id) + notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce')) + partner_ids = [(0, 0, { + "partner_id": notif.res_partner_id.id, + "name": notif.res_partner_id.name, + "email": notif.res_partner_id.email, + "resend": True, + "message": notif.format_failure_reason(), + }) for notif in notification_ids] + has_user = any(notif.res_partner_id.user_ids for notif in notification_ids) + if has_user: + partner_readonly = not self.env['res.users'].check_access_rights('write', raise_exception=False) + else: + partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False) + rec['partner_readonly'] = partner_readonly + rec['notification_ids'] = [(6, 0, notification_ids.ids)] + rec['mail_message_id'] = mail_message_id.id + rec['partner_ids'] = partner_ids + else: + raise UserError(_('No message_id found in context')) + return rec + + def resend_mail_action(self): + """ Process the wizard content and proceed with sending the related + email(s), rendering any template patterns on the fly if needed. """ + for wizard in self: + "If a partner disappeared from partner list, we cancel the notification" + to_cancel = wizard.partner_ids.filtered(lambda p: not p.resend).mapped("partner_id") + to_send = wizard.partner_ids.filtered(lambda p: p.resend).mapped("partner_id") + notif_to_cancel = wizard.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.res_partner_id in to_cancel and notif.notification_status in ('exception', 'bounce')) + notif_to_cancel.sudo().write({'notification_status': 'canceled'}) + if to_send: + message = wizard.mail_message_id + record = self.env[message.model].browse(message.res_id) if message.is_thread_message() else self.env['mail.thread'] + + email_partners_data = [] + for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(None, 'comment', False, pids=to_send.ids): + if pid and notif == 'email' or not notif: + pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []} + if not pshare and notif: # has an user and is not shared, is therefore user + email_partners_data.append(dict(pdata, type='user')) + elif pshare and notif: # has an user and is shared, is therefore portal + email_partners_data.append(dict(pdata, type='portal')) + else: # has no user, is therefore customer + email_partners_data.append(dict(pdata, type='customer')) + + record._notify_record_by_email(message, {'partners': email_partners_data}, check_existing=True, send_after_commit=False) + + self.mail_message_id._notify_message_notification_update() + return {'type': 'ir.actions.act_window_close'} + + def cancel_mail_action(self): + for wizard in self: + for notif in wizard.notification_ids: + notif.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce')).sudo().write({'notification_status': 'canceled'}) + wizard.mail_message_id._notify_message_notification_update() + return {'type': 'ir.actions.act_window_close'} + + +class PartnerResend(models.TransientModel): + _name = 'mail.resend.partner' + _description = 'Partner with additional information for mail resend' + + partner_id = fields.Many2one('res.partner', string='Partner', required=True, ondelete='cascade') + name = fields.Char(related="partner_id.name", related_sudo=False, readonly=False) + email = fields.Char(related="partner_id.email", related_sudo=False, readonly=False) + resend = fields.Boolean(string="Send Again", default=True) + resend_wizard_id = fields.Many2one('mail.resend.message', string="Resend wizard") + message = fields.Char(string="Help message") diff --git a/addons/mail/wizard/mail_resend_message_views.xml b/addons/mail/wizard/mail_resend_message_views.xml new file mode 100644 index 00000000..f9fb4ef5 --- /dev/null +++ b/addons/mail/wizard/mail_resend_message_views.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="mail_resend_message_view_form" model="ir.ui.view"> + <field name="name">mail.resend.message.view.form</field> + <field name="model">mail.resend.message</field> + <field name="groups_id" eval="[(4,ref('base.group_user'))]"/> + <field name="arch" type="xml"> + <form string="Edit Partners"> + <field name="mail_message_id" invisible="1"/> + <field name="notification_ids" invisible="1"/> + <field name="has_cancel" invisible="1"/> + <field name="partner_readonly" invisible="1"/> + <p>Select the action to do on each mail and correct the email address if needed. The modified address will be saved on the corresponding contact.</p> + <field name="partner_ids"> + <tree string="Recipient" editable="top" create="0" delete="0"> + <field name="name" readonly="1"/> + <field name="email" attrs="{'readonly': [('parent.partner_readonly', '=', True)]}"/> + <field name="message" readonly="1"/> + <field name="partner_id" invisible="1"/> + <field name="resend" widget="boolean_toggle"/> + </tree> + </field> + <div class="alert alert-warning" role="alert" attrs="{'invisible': [('has_cancel', '=', False)]}"> + <span class="fa fa-info-circle"/> Caution: It won't be possible to send this mail again to the recipients you did not select. + </div> + <footer> + <button string="Resend to selected" name="resend_mail_action" type="object" class="btn-primary o_mail_send"/> + <button string="Ignore all failures" name="cancel_mail_action" type="object" class="btn-secondary" /> + <button string="Cancel" class="btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> + <record id="mail_resend_message_action" model="ir.actions.act_window"> + <field name="name">Resend mail</field> + <field name="res_model">mail.resend.message</field> + <field name="type">ir.actions.act_window</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + </data> +</odoo> diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py new file mode 100644 index 00000000..03062bee --- /dev/null +++ b/addons/mail/wizard/mail_template_preview.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class MailTemplatePreview(models.TransientModel): + _name = 'mail.template.preview' + _description = 'Email Template Preview' + _MAIL_TEMPLATE_FIELDS = ['subject', 'body_html', 'email_from', 'email_to', + 'email_cc', 'reply_to', 'scheduled_date', 'attachment_ids'] + + @api.model + def _selection_target_model(self): + return [(model.model, model.name) for model in self.env['ir.model'].search([])] + + @api.model + def _selection_languages(self): + return self.env['res.lang'].get_installed() + + @api.model + def default_get(self, fields): + result = super(MailTemplatePreview, self).default_get(fields) + if not result.get('mail_template_id') or 'resource_ref' not in fields: + return result + mail_template = self.env['mail.template'].browse(result['mail_template_id']) + res = self.env[mail_template.model_id.model].search([], limit=1) + if res: + result['resource_ref'] = '%s,%s' % (mail_template.model_id.model, res.id) + return result + + mail_template_id = fields.Many2one('mail.template', string='Related Mail Template', required=True) + model_id = fields.Many2one('ir.model', string='Targeted model', related="mail_template_id.model_id") + resource_ref = fields.Reference(string='Record', selection='_selection_target_model') + lang = fields.Selection(_selection_languages, string='Template Preview Language') + no_record = fields.Boolean('No Record', compute='_compute_no_record') + error_msg = fields.Char('Error Message', readonly=True) + # Fields same than the mail.template model, computed with resource_ref and lang + subject = fields.Char('Subject', compute='_compute_mail_template_fields') + email_from = fields.Char('From', compute='_compute_mail_template_fields', help="Sender address") + email_to = fields.Char('To', compute='_compute_mail_template_fields', + help="Comma-separated recipient addresses") + email_cc = fields.Char('Cc', compute='_compute_mail_template_fields', help="Carbon copy recipients") + reply_to = fields.Char('Reply-To', compute='_compute_mail_template_fields', help="Preferred response address") + scheduled_date = fields.Char('Scheduled Date', compute='_compute_mail_template_fields', + help="The queue manager will send the email after the date") + body_html = fields.Html('Body', compute='_compute_mail_template_fields', sanitize=False) + attachment_ids = fields.Many2many('ir.attachment', 'Attachments', compute='_compute_mail_template_fields') + # Extra fields info generated by generate_email + partner_ids = fields.Many2many('res.partner', string='Recipients', compute='_compute_mail_template_fields') + + @api.depends('model_id') + def _compute_no_record(self): + for preview in self: + preview.no_record = (self.env[preview.model_id.model].search_count([]) == 0) if preview.model_id else True + + @api.depends('lang', 'resource_ref') + def _compute_mail_template_fields(self): + """ Preview the mail template (body, subject, ...) depending of the language and + the record reference, more precisely the record id for the defined model of the mail template. + If no record id is selectable/set, the jinja placeholders won't be replace in the display information. """ + copy_depends_values = {'lang': self.lang} + mail_template = self.mail_template_id.with_context(lang=self.lang) + try: + if not self.resource_ref: + self._set_mail_attributes() + else: + copy_depends_values['resource_ref'] = '%s,%s' % (self.resource_ref._name, self.resource_ref.id) + mail_values = mail_template.with_context(template_preview_lang=self.lang).generate_email( + self.resource_ref.id, self._MAIL_TEMPLATE_FIELDS) + self._set_mail_attributes(values=mail_values) + self.error_msg = False + except UserError as user_error: + self._set_mail_attributes() + self.error_msg = user_error.args[0] + finally: + # Avoid to be change by a invalidate_cache call (in generate_mail), e.g. Quotation / Order report + for key, value in copy_depends_values.items(): + self[key] = value + + def _set_mail_attributes(self, values=None): + for field in self._MAIL_TEMPLATE_FIELDS: + field_value = values.get(field, False) if values else self.mail_template_id[field] + self[field] = field_value + self.partner_ids = values.get('partner_ids', False) if values else False diff --git a/addons/mail/wizard/mail_template_preview_views.xml b/addons/mail/wizard/mail_template_preview_views.xml new file mode 100644 index 00000000..ab3d0bd5 --- /dev/null +++ b/addons/mail/wizard/mail_template_preview_views.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="mail_template_preview_view_form" model="ir.ui.view"> + <field name="name">mail.template.preview.view.form</field> + <field name="model">mail.template.preview</field> + <field name="arch" type="xml"> + <form string="Email Preview"> + <h3>Preview of <field name="mail_template_id" readonly="1" nolabel="1" options="{'no_open' : True}"/></h3> + <div class="alert alert-danger" role="alert" attrs="{'invisible' : [('error_msg', '=', False)]}"> + <field name="error_msg" /> + </div> + <field name="no_record" invisible="1"/> + <div class="container"> + <div class="row"> + <span class="col-md-5 col-lg-4 col-sm-12 pl-0">Choose an example <field name="model_id" readonly="1"/> record:</span> + <div class="col-md-7 col-lg-6 col-sm-12 pl-0"> + <field name="resource_ref" readonly="False" + options="{'hide_model': True, 'no_create': True, 'no_edit': True, 'no_open': True}" + attrs="{'invisible': [('no_record', '=', True)]}"/> + <b attrs="{'invisible': [('no_record', '=', False)]}" class="text-warning">No record for this model</b> + </div> + </div> + <div class="row"> + <span class="col-md-5 col-lg-4 col-sm-12 pl-0">Force a language: </span> + <div class="col-md-7 col-lg-6 col-sm-12 pl-0"> + <field name="lang"/> + </div> + </div> + </div> + <group> + <field name="subject"/> + <field name="email_from" attrs="{'invisible':[('email_from','=', False)]}"/> + <field name="partner_ids" widget="many2many_tags" attrs="{'invisible':[('partner_ids', '=', [])]}"/> + <field name="email_to" attrs="{'invisible':[('email_to','=', False)]}"/> + <field name="email_cc" attrs="{'invisible':[('email_cc','=', False)]}"/> + <field name="reply_to" attrs="{'invisible':[('reply_to','=', False)]}"/> + <field name="scheduled_date" attrs="{'invisible':[('scheduled_date','=', False)]}"/> + </group> + <field name="body_html" widget="html" nolabel="1" options='{"safe": True}'/> + <field name="attachment_ids" widget="many2many_binary"/> + <footer> + <button string="Close" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + <record id="mail_template_preview_action" model="ir.actions.act_window"> + <field name="name">Template Preview</field> + <field name="res_model">mail.template.preview</field> + <field name="binding_model_id" eval="False"/> + <field name="type">ir.actions.act_window</field> + <field name="view_mode">form</field> + <field name="view_id" ref="mail_template_preview_view_form"/> + <field name="target">new</field> + <field name="context">{'default_mail_template_id':active_id}</field> + </record> + + </data> +</odoo> |
