[IMP] mass_mailing(_sms): a/b testing
Manage correctly a/b testing in mass_mailing. We can now organize a mass_mailing campaign by testing multiple mailings for the recipients targeted (subject, templates, design, ...) For this a new tab on the mailing record is added to better promote the existence of the feature. When an user has the group to manage mailing campaign, he can access the tab A/B Test. This tab allows to enable A/B testing for the mailing. If there is no campaign set for the mailing, one is automatically created allowing the user to continue smoothly. Once A/B testing enable, the percentage of recipients use can be set for each mailings. Also, the user can choose the deciding factor that will set the final mailing as winner. In a case, the user is not in manual mode, he can set the schedule datetime for sending the final mailing. task-2123242 COM PR: odoo/odoo#75622 ENT PR: odoo/enterprise#20454 UPG PR: odoo/upgrade#2781 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -1,6 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<template id="ab_testing_description" name="Mass Mailing: A/B Test Description">
|
||||
<div name="ab_testing_description" class="mb-2">
|
||||
<t t-if="mailing.ab_testing_completed">
|
||||
<p t-if="mailing.ab_testing_pc == 100">
|
||||
This <t t-out="mailing.mailing_type_description"/> is the winner of the A/B testing campaign and has been sent to all remaining recipients.
|
||||
</p>
|
||||
<p t-else="">The winner has already been sent. Use <b>Compare Version</b> to get an overview of this A/B testing campaign.</p>
|
||||
</t>
|
||||
<t t-elif="mailing.ab_testing_mailings_count >= 2">
|
||||
<p>
|
||||
A sample of <b><t t-out="mailing.ab_testing_pc"/>% of recipients</b> will receive this <b><t t-out="mailing.mailing_type_description"/></b>, and <t t-out="other_ab_testing_pc"/>% receive one of the
|
||||
<b><t t-out="mailing.ab_testing_mailings_count - 1"/> other versions</b> from the same campaign.
|
||||
</p>
|
||||
<p>
|
||||
<t t-if="mailing.ab_testing_winner_selection == 'manual'">Don't forget to send your prefered version</t>
|
||||
<t t-else="">
|
||||
Then on <b><t t-out="mailing.ab_testing_schedule_datetime.strftime('%b %d, %Y')"/></b> the <t t-out="mailing.mailing_type_description"/> having the <b><t t-out="ab_testing_winner_selection_description"/></b> will be sent
|
||||
</t> to the remaining <t t-out="remaining_ab_testing_pc"/>% of recipients.
|
||||
</p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p>
|
||||
A sample of <b><t t-out="mailing.ab_testing_pc"/>% of recipients</b> will receive this <b><t t-out="mailing.mailing_type_description"/></b>.
|
||||
</p>
|
||||
<p>Try different variations in the campaign to compare their <t t-out="ab_testing_winner_selection_description"/>.</p>
|
||||
<p>
|
||||
<t t-if="mailing.ab_testing_winner_selection != 'manual'">Once the best version is identified, we will send the best one to the remaining recipients.</t>
|
||||
<t t-else="">
|
||||
The actual <t t-out="mailing.mailing_type_description"/> will be sent to the remaining recipients.
|
||||
</t>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="mass_mailing.mass_mailing_kpi_link_trackers" name="Marketing: mailing link trackers statistic">
|
||||
<div class="global_layout" t-if="link_trackers">
|
||||
<table bgcolor="#ffffff" cellspacing="0" cellpadding="0" width="650" align="center" border="0" style="width: 100%; max-width: 650px;">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Cron that process the mass mailing queue -->
|
||||
<!-- Cron that processes the mass mailing queue -->
|
||||
<record id="ir_cron_mass_mailing_queue" model="ir.cron">
|
||||
<field name="name">Mail Marketing: Process queue</field>
|
||||
<field name="model_id" ref="model_mailing_mailing"/>
|
||||
@@ -13,6 +13,18 @@
|
||||
<field name="numbercall">-1</field>
|
||||
<field eval="False" name="doall" />
|
||||
</record>
|
||||
<!-- Cron that processes the a/b testing -->
|
||||
<record id="ir_cron_mass_mailing_ab_testing" model="ir.cron">
|
||||
<field name="name">Mail Marketing: A/B Testing</field>
|
||||
<field name="model_id" ref="model_utm_campaign"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_process_mass_mailing_ab_testing()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="True"/>
|
||||
</record>
|
||||
<record id="mailing_list_data" model="mailing.list">
|
||||
<field name="name">Newsletter</field>
|
||||
</record>
|
||||
|
||||
@@ -14,7 +14,7 @@ from dateutil.relativedelta import relativedelta
|
||||
from werkzeug.urls import url_join
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.osv import expression
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -104,6 +104,7 @@ class MassMailing(models.Model):
|
||||
user_id = fields.Many2one('res.users', string='Responsible', tracking=True, default=lambda self: self.env.user)
|
||||
# mailing options
|
||||
mailing_type = fields.Selection([('mail', 'Email')], string="Mailing Type", default="mail", required=True)
|
||||
mailing_type_description = fields.Char('Mailing Type Description', compute="_compute_mailing_type_description")
|
||||
reply_to_mode = fields.Selection([
|
||||
('update', 'Recipient Followers'), ('new', 'Specified Email Address')],
|
||||
string='Reply-To Mode', compute='_compute_reply_to_mode',
|
||||
@@ -131,12 +132,21 @@ class MassMailing(models.Model):
|
||||
default=_get_default_mail_server_id,
|
||||
help="Use a specific mail server in priority. Otherwise Odoo relies on the first outgoing mail server available (based on their sequencing) as it does for normal mails.")
|
||||
contact_list_ids = fields.Many2many('mailing.list', 'mail_mass_mailing_list_rel', string='Mailing Lists')
|
||||
contact_ab_pc = fields.Integer(string='A/B Testing percentage',
|
||||
help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.', default=100)
|
||||
unique_ab_testing = fields.Boolean(string='Allow A/B Testing', default=False,
|
||||
# A/B Testing
|
||||
ab_testing_completed = fields.Boolean(related='campaign_id.ab_testing_completed', store=True)
|
||||
ab_testing_description = fields.Html('A/B Testing Description', compute="_compute_ab_testing_description")
|
||||
ab_testing_enabled = fields.Boolean(string='Allow A/B Testing', default=False,
|
||||
help='If checked, recipients will be mailed only once for the whole campaign. '
|
||||
'This lets you send different mailings to randomly selected recipients and test '
|
||||
'the effectiveness of the mailings, without causing duplicate messages.')
|
||||
ab_testing_mailings_count = fields.Integer(related="campaign_id.ab_testing_mailings_count")
|
||||
ab_testing_pc = fields.Integer(string='A/B Testing percentage',
|
||||
help='Percentage of the contacts that will be mailed. Recipients will be chosen randomly.', default=10)
|
||||
ab_testing_schedule_datetime = fields.Datetime(related="campaign_id.ab_testing_schedule_datetime", readonly=False,
|
||||
default=lambda self: fields.Datetime.now() + relativedelta(days=1))
|
||||
ab_testing_winner_selection = fields.Selection(related="campaign_id.ab_testing_winner_selection",
|
||||
default="opened_ratio", readonly=False, copy=True)
|
||||
|
||||
kpi_mail_required = fields.Boolean('KPI mail required', copy=False)
|
||||
# statistics data
|
||||
mailing_trace_ids = fields.One2many('mailing.trace', 'mass_mailing_id', string='Emails Statistics')
|
||||
@@ -162,6 +172,12 @@ class MassMailing(models.Model):
|
||||
'Warning Message', compute='_compute_warning_message',
|
||||
help='Warning message displayed in the mailing form view')
|
||||
|
||||
_sql_constraints = [(
|
||||
'percentage_valid',
|
||||
'CHECK(ab_testing_pc >= 0 AND ab_testing_pc <= 100)',
|
||||
'The A/B Testing Percentage needs to be between 0 and 100%'
|
||||
)]
|
||||
|
||||
@api.depends('mail_server_id')
|
||||
def _compute_email_from(self):
|
||||
user_email = self.env.user.email_formatted
|
||||
@@ -183,8 +199,8 @@ class MassMailing(models.Model):
|
||||
def _compute_total(self):
|
||||
for mass_mailing in self:
|
||||
total = self.env[mass_mailing.mailing_model_real].search_count(mass_mailing._parse_mailing_domain())
|
||||
if mass_mailing.contact_ab_pc < 100:
|
||||
total = int(total / 100.0 * mass_mailing.contact_ab_pc)
|
||||
if mass_mailing.ab_testing_pc < 100:
|
||||
total = int(total / 100.0 * mass_mailing.ab_testing_pc)
|
||||
mass_mailing.total = total
|
||||
|
||||
def _compute_clicks_ratio(self):
|
||||
@@ -333,6 +349,24 @@ class MassMailing(models.Model):
|
||||
for mailing in self:
|
||||
mailing.render_model = mailing.mailing_model_real
|
||||
|
||||
@api.depends('mailing_type')
|
||||
def _compute_mailing_type_description(self):
|
||||
for mailing in self:
|
||||
mailing.mailing_type_description = dict(self._fields.get('mailing_type').selection).get(mailing.mailing_type)
|
||||
|
||||
@api.depends(lambda self: self._get_ab_testing_description_modifying_fields())
|
||||
def _compute_ab_testing_description(self):
|
||||
mailing_ab_test = self.filtered('ab_testing_enabled')
|
||||
(self - mailing_ab_test).ab_testing_description = False
|
||||
for mailing in mailing_ab_test:
|
||||
mailing.ab_testing_description = self.env['ir.qweb']._render(
|
||||
'mass_mailing.ab_testing_description',
|
||||
mailing._get_ab_testing_description_values()
|
||||
)
|
||||
|
||||
def _get_ab_testing_description_modifying_fields(self):
|
||||
return ['ab_testing_enabled', 'ab_testing_pc', 'ab_testing_schedule_datetime', 'ab_testing_winner_selection', 'campaign_id']
|
||||
|
||||
# ------------------------------------------------------
|
||||
# ORM
|
||||
# ------------------------------------------------------
|
||||
@@ -340,18 +374,42 @@ class MassMailing(models.Model):
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
now = fields.Datetime.now()
|
||||
ab_testing_cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo()
|
||||
for values in vals_list:
|
||||
if values.get('subject') and not values.get('name'):
|
||||
values['name'] = "%s %s" % (values['subject'], now)
|
||||
if values.get('body_html'):
|
||||
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
|
||||
return super().create(vals_list)
|
||||
if values.get('ab_testing_schedule_datetime'):
|
||||
at = fields.Datetime.from_string(values['ab_testing_schedule_datetime'])
|
||||
ab_testing_cron._trigger(at=at)
|
||||
mailings = super().create(vals_list)
|
||||
campaign_vals = [
|
||||
mailing._get_default_ab_testing_campaign_values()
|
||||
for mailing in mailings
|
||||
if mailing.ab_testing_enabled and not mailing.campaign_id
|
||||
]
|
||||
self.env['utm.campaign'].create(campaign_vals)
|
||||
return mailings
|
||||
|
||||
def write(self, values):
|
||||
if values.get('body_html'):
|
||||
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
|
||||
# When ab_testing_enabled is checked we create a campaign if there is none set.
|
||||
if values.get('ab_testing_enabled') and not values.get('campaign_id'):
|
||||
# Compute the values of the A/B test campaign based on the first mailing
|
||||
values['campaign_id'] = self.env['utm.campaign'].create(self[0]._get_default_ab_testing_campaign_values(values)).id
|
||||
# If ab_testing is already enabled on a mailing and the campaign is removed, we raise a ValidationError
|
||||
if values.get('campaign_id') is False and any(mailing.ab_testing_enabled for mailing in self) and 'ab_testing_enabled' not in values:
|
||||
raise ValidationError(_("A campaign should be set when A/B test is enabled"))
|
||||
|
||||
return super(MassMailing, self).write(values)
|
||||
result = super(MassMailing, self).write(values)
|
||||
|
||||
if self.ab_testing_schedule_datetime:
|
||||
ab_testing_cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo()
|
||||
ab_testing_cron._trigger(at=self.ab_testing_schedule_datetime)
|
||||
|
||||
return result
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
@@ -374,13 +432,18 @@ class MassMailing(models.Model):
|
||||
if mass_mailing_copy:
|
||||
context = dict(self.env.context)
|
||||
context['form_view_initial_mode'] = 'edit'
|
||||
return {
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'mailing.mailing',
|
||||
'res_id': mass_mailing_copy.id,
|
||||
'context': context,
|
||||
}
|
||||
if self.mailing_type == 'mail':
|
||||
action['views'] = [
|
||||
(self.env.ref('mass_mailing.mailing_mailing_view_form_full_width').id, 'form'),
|
||||
]
|
||||
return action
|
||||
return False
|
||||
|
||||
def action_test(self):
|
||||
@@ -518,6 +581,109 @@ class MassMailing(models.Model):
|
||||
if len(record_lists) > 0:
|
||||
record.sudo().message_post(body=message % ', '.join(str(list.name) for list in record_lists.mapped('list_id')))
|
||||
|
||||
# ------------------------------------------------------
|
||||
# A/B Test
|
||||
# ------------------------------------------------------
|
||||
|
||||
def action_compare_versions(self):
|
||||
self.ensure_one()
|
||||
if not self.campaign_id:
|
||||
raise ValueError(_("No mailing campaign has been found"))
|
||||
action = {
|
||||
'name': _('A/B Tests'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'tree,kanban,form,calendar,graph',
|
||||
'res_model': 'mailing.mailing',
|
||||
'domain': [('campaign_id', '=', self.campaign_id.id), ('ab_testing_enabled', '=', True)],
|
||||
}
|
||||
if self.mailing_type == 'mail':
|
||||
action['views'] = [
|
||||
(False, 'tree'),
|
||||
(False, 'kanban'),
|
||||
(self.env.ref('mass_mailing.mailing_mailing_view_form_full_width').id, 'form'),
|
||||
(False, 'calendar'),
|
||||
(False, 'graph'),
|
||||
]
|
||||
return action
|
||||
|
||||
def action_send_winner_mailing(self):
|
||||
"""Send the winner mailing based on the winner selection field.
|
||||
This action is used in 2 cases:
|
||||
- When the user clicks on a button to send the winner mailing. There is only one mailing in self
|
||||
- When the cron is executed to send winner mailing based on the A/B testing schedule datetime. In this
|
||||
case 'self' contains all the mailing for the campaigns so we just need to take the first to determine the
|
||||
winner.
|
||||
If the winner mailing is computed automatically, we sudo the mailings of the campaign in order to sort correctly
|
||||
the mailings based on the selection that can be used with sub-modules like CRM and Sales
|
||||
"""
|
||||
if len(self.campaign_id) != 1:
|
||||
raise ValueError(_("To send the winner mailing the same campaign should be used by the mailings"))
|
||||
if any(mailing.ab_testing_completed for mailing in self):
|
||||
raise ValueError(_("To send the winner mailing the campaign should not have been completed."))
|
||||
final_mailing = self[0]
|
||||
sorted_by = final_mailing._get_ab_testing_winner_selection()['value']
|
||||
if sorted_by != 'manual':
|
||||
ab_testing_mailings = final_mailing._get_ab_testing_siblings_mailings().sudo()
|
||||
selected_mailings = ab_testing_mailings.filtered(lambda m: m.state == 'done').sorted(sorted_by, reverse=True)
|
||||
if selected_mailings:
|
||||
final_mailing = selected_mailings[0]
|
||||
else:
|
||||
raise ValidationError(_("No mailing for this A/B testing campaign has been sent yet! Send one first and try again later."))
|
||||
return final_mailing.action_select_as_winner()
|
||||
|
||||
def action_select_as_winner(self):
|
||||
self.ensure_one()
|
||||
if not self.ab_testing_enabled:
|
||||
raise ValueError(_("A/B test option has not been enabled"))
|
||||
self.campaign_id.write({
|
||||
'ab_testing_completed': True,
|
||||
})
|
||||
final_mailing = self.copy({
|
||||
'ab_testing_pc': 100,
|
||||
})
|
||||
final_mailing.action_launch()
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('mass_mailing.action_ab_testing_open_winner_mailing')
|
||||
action['res_id'] = final_mailing.id
|
||||
if self.mailing_type == 'mail':
|
||||
action['views'] = [
|
||||
(self.env.ref('mass_mailing.mailing_mailing_view_form_full_width').id, 'form'),
|
||||
]
|
||||
return action
|
||||
|
||||
def _get_ab_testing_description_values(self):
|
||||
self.ensure_one()
|
||||
|
||||
other_ab_testing_mailings = self._get_ab_testing_siblings_mailings().filtered(lambda m: m.id != self.id)
|
||||
other_ab_testing_pc = sum([mailing.ab_testing_pc for mailing in other_ab_testing_mailings])
|
||||
return {
|
||||
'mailing': self,
|
||||
'ab_testing_winner_selection_description': self._get_ab_testing_winner_selection()['description'],
|
||||
'other_ab_testing_pc': other_ab_testing_pc,
|
||||
'remaining_ab_testing_pc': 100 - (other_ab_testing_pc + self.ab_testing_pc),
|
||||
}
|
||||
|
||||
def _get_ab_testing_siblings_mailings(self):
|
||||
return self.campaign_id.mailing_mail_ids.filtered(lambda m: m.ab_testing_enabled)
|
||||
|
||||
def _get_ab_testing_winner_selection(self):
|
||||
ab_testing_winner_selection_description = dict(
|
||||
self._fields.get('ab_testing_winner_selection').related_field.selection
|
||||
).get(self.ab_testing_winner_selection)
|
||||
return {
|
||||
'value': self.ab_testing_winner_selection,
|
||||
'description': ab_testing_winner_selection_description,
|
||||
}
|
||||
|
||||
def _get_default_ab_testing_campaign_values(self, values=None):
|
||||
values = values or dict()
|
||||
return {
|
||||
'ab_testing_schedule_datetime': values.get('ab_testing_schedule_datetime') or self.ab_testing_schedule_datetime,
|
||||
'ab_testing_winner_selection': values.get('ab_testing_winner_selection') or self.ab_testing_winner_selection,
|
||||
'mailing_mail_ids': self.ids,
|
||||
'name': _('A/B Test: %s', values.get('subject') or self.subject or fields.Datetime.now()),
|
||||
'user_id': values.get('user_id') or self.user_id.id or self.env.user.id,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Email Sending
|
||||
# ------------------------------------------------------
|
||||
@@ -587,12 +753,12 @@ class MassMailing(models.Model):
|
||||
else:
|
||||
raise UserError(_("Unsupported mass mailing model %s", self.mailing_model_id.name))
|
||||
|
||||
if self.unique_ab_testing:
|
||||
query +="""
|
||||
if self.ab_testing_enabled:
|
||||
query += """
|
||||
AND s.campaign_id = %%(mailing_campaign_id)s;
|
||||
"""
|
||||
else:
|
||||
query +="""
|
||||
query += """
|
||||
AND s.mass_mailing_id = %%(mailing_id)s
|
||||
AND s.model = %%(target_model)s;
|
||||
"""
|
||||
@@ -615,25 +781,30 @@ class MassMailing(models.Model):
|
||||
res_ids = self.env[self.mailing_model_real].search(mailing_domain).ids
|
||||
|
||||
# randomly choose a fragment
|
||||
if self.contact_ab_pc < 100:
|
||||
if self.ab_testing_enabled and self.ab_testing_pc < 100:
|
||||
contact_nbr = self.env[self.mailing_model_real].search_count(mailing_domain)
|
||||
topick = int(contact_nbr / 100.0 * self.contact_ab_pc)
|
||||
if self.campaign_id and self.unique_ab_testing:
|
||||
topick = int(contact_nbr / 100.0 * self.ab_testing_pc)
|
||||
if self.campaign_id and self.ab_testing_enabled:
|
||||
already_mailed = self.campaign_id._get_mailing_recipients()[self.campaign_id.id]
|
||||
else:
|
||||
already_mailed = set([])
|
||||
remaining = set(res_ids).difference(already_mailed)
|
||||
if topick > len(remaining):
|
||||
if topick > len(remaining) or (len(remaining) > 0 and topick == 0):
|
||||
topick = len(remaining)
|
||||
res_ids = random.sample(remaining, topick)
|
||||
return res_ids
|
||||
|
||||
def _get_remaining_recipients(self):
|
||||
res_ids = self._get_recipients()
|
||||
already_mailed = self.env['mailing.trace'].search_read([
|
||||
('model', '=', self.mailing_model_real),
|
||||
('res_id', 'in', res_ids),
|
||||
('mass_mailing_id', '=', self.id)], ['res_id'])
|
||||
trace_domain = [('model', '=', self.mailing_model_real)]
|
||||
if self.ab_testing_enabled and self.ab_testing_pc == 100:
|
||||
trace_domain = expression.AND([trace_domain, [('mass_mailing_id', '=', self._get_ab_testing_siblings_mailings().ids)]])
|
||||
else:
|
||||
trace_domain = expression.AND([trace_domain, [
|
||||
('res_id', 'in', res_ids),
|
||||
('mass_mailing_id', '=', self.id),
|
||||
]])
|
||||
already_mailed = self.env['mailing.trace'].search_read(trace_domain, ['res_id'])
|
||||
done_res_ids = {record['res_id'] for record in already_mailed}
|
||||
return [rid for rid in res_ids if rid not in done_res_ids]
|
||||
|
||||
|
||||
@@ -19,3 +19,9 @@ class ResConfigSettings(models.TransientModel):
|
||||
def _onchange_mass_mailing_outgoing_mail_server(self):
|
||||
if not self.mass_mailing_outgoing_mail_server:
|
||||
self.mass_mailing_mail_server_id = False
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ab_test_cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo()
|
||||
if ab_test_cron and ab_test_cron.active != self.group_mass_mailing_campaign:
|
||||
ab_test_cron.active = self.group_mass_mailing_campaign
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
@@ -12,25 +15,61 @@ class UtmCampaign(models.Model):
|
||||
domain=[('mailing_type', '=', 'mail')],
|
||||
string='Mass Mailings')
|
||||
mailing_mail_count = fields.Integer('Number of Mass Mailing', compute="_compute_mailing_mail_count")
|
||||
|
||||
# A/B Testing
|
||||
ab_testing_mailings_count = fields.Integer("A/B Test Mailings #", compute="_compute_mailing_mail_count")
|
||||
ab_testing_completed = fields.Boolean("A/B Testing Campaign Finished")
|
||||
ab_testing_schedule_datetime = fields.Datetime('Send Final On',
|
||||
default=lambda self: fields.Datetime.now() + relativedelta(days=1),
|
||||
help="Date that will be used to know when to determine and send the winner mailing")
|
||||
ab_testing_total_pc = fields.Integer("Total A/B test percentage", compute="_compute_ab_testing_total_pc", store=True)
|
||||
ab_testing_winner_selection = fields.Selection([
|
||||
('manual', 'Manual'),
|
||||
('opened_ratio', 'Highest Open Rate'),
|
||||
('clicks_ratio', 'Highest Click Rate'),
|
||||
('replied_ratio', 'Highest Reply Rate')], string="Winner Selection", default="opened_ratio",
|
||||
help="Selection to determine the winner mailing that will be sent.")
|
||||
|
||||
# stat fields
|
||||
received_ratio = fields.Integer(compute="_compute_statistics", string='Received Ratio')
|
||||
opened_ratio = fields.Integer(compute="_compute_statistics", string='Opened Ratio')
|
||||
replied_ratio = fields.Integer(compute="_compute_statistics", string='Replied Ratio')
|
||||
bounced_ratio = fields.Integer(compute="_compute_statistics", string='Bounced Ratio')
|
||||
|
||||
@api.depends('mailing_mail_ids')
|
||||
def _compute_ab_testing_total_pc(self):
|
||||
for campaign in self:
|
||||
campaign.ab_testing_total_pc = sum([
|
||||
mailing.ab_testing_pc for mailing in campaign.mailing_mail_ids.filtered('ab_testing_enabled')
|
||||
])
|
||||
|
||||
@api.depends('mailing_mail_ids')
|
||||
def _compute_mailing_mail_count(self):
|
||||
if self.ids:
|
||||
mailing_data = self.env['mailing.mailing'].read_group(
|
||||
[('campaign_id', 'in', self.ids)],
|
||||
['campaign_id'],
|
||||
['campaign_id']
|
||||
['campaign_id', 'ab_testing_enabled'],
|
||||
['campaign_id', 'ab_testing_enabled'],
|
||||
lazy=False,
|
||||
)
|
||||
mapped_data = {m['campaign_id'][0]: m['campaign_id_count'] for m in mailing_data}
|
||||
ab_testing_mapped_data = {}
|
||||
mapped_data = {}
|
||||
for data in mailing_data:
|
||||
if data['ab_testing_enabled']:
|
||||
ab_testing_mapped_data.setdefault(data['campaign_id'][0], []).append(data['__count'])
|
||||
mapped_data.setdefault(data['campaign_id'][0], []).append(data['__count'])
|
||||
else:
|
||||
mapped_data = dict()
|
||||
ab_testing_mapped_data = dict()
|
||||
for campaign in self:
|
||||
campaign.mailing_mail_count = mapped_data.get(campaign.id, 0)
|
||||
campaign.mailing_mail_count = sum(mapped_data.get(campaign.id, []))
|
||||
campaign.ab_testing_mailings_count = sum(ab_testing_mapped_data.get(campaign.id, []))
|
||||
|
||||
@api.constrains('ab_testing_total_pc', 'ab_testing_completed')
|
||||
def _check_ab_testing_total_pc(self):
|
||||
for campaign in self:
|
||||
if not campaign.ab_testing_completed and campaign.ab_testing_total_pc >= 100:
|
||||
raise ValidationError(_("The total percentage for an A/B testing campaign should be less than 100%"))
|
||||
|
||||
def _compute_statistics(self):
|
||||
""" Compute statistics of the mass mailing campaign """
|
||||
@@ -96,3 +135,21 @@ class UtmCampaign(models.Model):
|
||||
domain += [('model', '=', model)]
|
||||
res[campaign.id] = set(self.env['mailing.trace'].search(domain).mapped('res_id'))
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _cron_process_mass_mailing_ab_testing(self):
|
||||
""" Cron that manages A/B testing and sends a winner mailing computed based on
|
||||
the value set on the A/B testing campaign.
|
||||
In case there is no mailing sent for an A/B testing campaign we ignore this campaign
|
||||
"""
|
||||
ab_testing_campaign = self.search([
|
||||
('ab_testing_schedule_datetime', '<=', fields.Datetime.now()),
|
||||
('ab_testing_winner_selection', '!=', 'manual'),
|
||||
('ab_testing_completed', '=', False),
|
||||
])
|
||||
for campaign in ab_testing_campaign:
|
||||
ab_testing_mailings = campaign.mailing_mail_ids.filtered(lambda m: m.ab_testing_enabled)
|
||||
if not ab_testing_mailings.filtered(lambda m: m.state == 'done'):
|
||||
continue
|
||||
ab_testing_mailings.action_send_winner_mailing()
|
||||
return ab_testing_campaign
|
||||
|
||||
@@ -343,7 +343,7 @@ var MassMailingFieldHtml = FieldHtml.extend({
|
||||
}
|
||||
this._super();
|
||||
this.wysiwyg.odooEditor.observerFlush();
|
||||
this.wysiwyg.odooEditor.resetHistory();
|
||||
this.wysiwyg.odooEditor.historyReset();
|
||||
this.wysiwyg.$iframeBody.addClass('o_mass_mailing_iframe');
|
||||
this.trigger_up('iframe_updated', { $iframe: this.wysiwyg.$iframe });
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
from . import test_mailing_ab_testing
|
||||
from . import test_mailing_internals
|
||||
from . import test_mailing_list
|
||||
from . import test_mailing_controllers
|
||||
|
||||
@@ -254,3 +254,15 @@ class MassMailCommon(MailCommon, MassMailCase):
|
||||
(0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}),
|
||||
]
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _create_mailing_list_of_x_contacts(cls, contacts_nbr):
|
||||
""" Shortcut to create a mailing list that contains a defined number
|
||||
of contacts. """
|
||||
return cls.env['mailing.list'].with_context(cls._test_context).create({
|
||||
'name': 'Test List',
|
||||
'contact_ids': [
|
||||
(0, 0, {'name': 'Contact %s' % i, 'email': 'contact%s@example.com' % i})
|
||||
for i in range(contacts_nbr)
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo.addons.mass_mailing.tests.common import MassMailCommon
|
||||
from odoo.tests import users, tagged
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMailingABTesting(MassMailCommon):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.mailing_list = self._create_mailing_list_of_x_contacts(150)
|
||||
self.ab_testing_mailing_1 = self.env['mailing.mailing'].create({
|
||||
'subject': 'A/B Testing V1',
|
||||
'contact_list_ids': self.mailing_list.ids,
|
||||
'ab_testing_enabled': True,
|
||||
'ab_testing_pc': 10,
|
||||
'ab_testing_schedule_datetime': datetime.now(),
|
||||
})
|
||||
self.ab_testing_mailing_2 = self.ab_testing_mailing_1.copy({
|
||||
'subject': 'A/B Testing V2',
|
||||
'ab_testing_pc': 20,
|
||||
})
|
||||
self.ab_testing_campaign = self.ab_testing_mailing_1.campaign_id
|
||||
self.ab_testing_mailing_ids = self.ab_testing_mailing_1 + self.ab_testing_mailing_2
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_mail')
|
||||
@users('user_marketing')
|
||||
def test_mailing_ab_testing_auto_flow(self):
|
||||
with self.mock_mail_gateway():
|
||||
self.ab_testing_mailing_ids.action_send_mail()
|
||||
self.assertEqual(self.ab_testing_mailing_1.state, 'done')
|
||||
self.assertEqual(self.ab_testing_mailing_2.state, 'done')
|
||||
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 0)
|
||||
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 0)
|
||||
|
||||
total_trace_ids = self.ab_testing_mailing_ids.mailing_trace_ids
|
||||
unique_recipients_used = set(map(lambda mail: mail.res_id, total_trace_ids.mail_mail_id))
|
||||
self.assertEqual(len(self.ab_testing_mailing_1.mailing_trace_ids), 15)
|
||||
self.assertEqual(len(self.ab_testing_mailing_2.mailing_trace_ids), 30)
|
||||
self.assertEqual(len(unique_recipients_used), 45)
|
||||
|
||||
self.ab_testing_mailing_1.mailing_trace_ids[:10].set_opened()
|
||||
self.ab_testing_mailing_2.mailing_trace_ids[:15].set_opened()
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
|
||||
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 66)
|
||||
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 50)
|
||||
|
||||
with self.mock_mail_gateway():
|
||||
self.ab_testing_mailing_2.action_send_winner_mailing()
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
winner_mailing = self.ab_testing_campaign.mailing_mail_ids.filtered(lambda mailing: mailing.ab_testing_pc == 100)
|
||||
self.assertEqual(winner_mailing.subject, 'A/B Testing V1')
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_mail')
|
||||
@users('user_marketing')
|
||||
def test_mailing_ab_testing_auto_flow_cron(self):
|
||||
self.ab_testing_mailing_1.write({
|
||||
'ab_testing_schedule_datetime': datetime.now() + timedelta(days=-1),
|
||||
})
|
||||
with self.mock_mail_gateway():
|
||||
self.ab_testing_mailing_ids.action_send_mail()
|
||||
self.assertEqual(self.ab_testing_mailing_1.state, 'done')
|
||||
self.assertEqual(self.ab_testing_mailing_2.state, 'done')
|
||||
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 0)
|
||||
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 0)
|
||||
|
||||
total_trace_ids = self.ab_testing_mailing_ids.mailing_trace_ids
|
||||
unique_recipients_used = set(map(lambda mail: mail.res_id, total_trace_ids.mail_mail_id))
|
||||
self.assertEqual(len(self.ab_testing_mailing_1.mailing_trace_ids), 15)
|
||||
self.assertEqual(len(self.ab_testing_mailing_2.mailing_trace_ids), 30)
|
||||
self.assertEqual(len(unique_recipients_used), 45)
|
||||
|
||||
self.ab_testing_mailing_1.mailing_trace_ids[:10].set_opened()
|
||||
self.ab_testing_mailing_2.mailing_trace_ids[:15].set_opened()
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
|
||||
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 66)
|
||||
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 50)
|
||||
|
||||
with self.mock_mail_gateway():
|
||||
self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo().method_direct_trigger()
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
winner_mailing = self.ab_testing_campaign.mailing_mail_ids.filtered(lambda mailing: mailing.ab_testing_pc == 100)
|
||||
self.assertEqual(winner_mailing.subject, 'A/B Testing V1')
|
||||
|
||||
@users('user_marketing')
|
||||
def test_mailing_ab_testing_campaign(self):
|
||||
schedule_datetime = datetime.now() + timedelta(days=30)
|
||||
ab_mailing = self.env['mailing.mailing'].create({
|
||||
'subject': 'A/B Testing V1',
|
||||
'contact_list_ids': self.mailing_list.ids,
|
||||
'ab_testing_enabled': True,
|
||||
'ab_testing_winner_selection': 'manual',
|
||||
'ab_testing_schedule_datetime': schedule_datetime,
|
||||
})
|
||||
ab_mailing.invalidate_cache()
|
||||
|
||||
# Check if the campaign is correclty created and the values set on the mailing are still the same
|
||||
self.assertTrue(ab_mailing.campaign_id, "A campaign id is present for the A/B test mailing")
|
||||
self.assertEqual(ab_mailing.ab_testing_winner_selection, 'manual', "The selection winner has been propagated correctly")
|
||||
self.assertEqual(ab_mailing.ab_testing_schedule_datetime, schedule_datetime, "The schedule date has been propagated correctly")
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_mail')
|
||||
@users('user_marketing')
|
||||
def test_mailing_ab_testing_manual_flow(self):
|
||||
self.ab_testing_mailing_1.write({
|
||||
'ab_testing_winner_selection': 'manual',
|
||||
})
|
||||
with self.mock_mail_gateway():
|
||||
self.ab_testing_mailing_ids.action_send_mail()
|
||||
self.assertEqual(self.ab_testing_mailing_1.state, 'done')
|
||||
self.assertEqual(self.ab_testing_mailing_2.state, 'done')
|
||||
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 0)
|
||||
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 0)
|
||||
|
||||
total_trace_ids = self.ab_testing_mailing_ids.mailing_trace_ids
|
||||
unique_recipients_used = set(map(lambda mail: mail.res_id, total_trace_ids.mail_mail_id))
|
||||
self.assertEqual(len(self.ab_testing_mailing_1.mailing_trace_ids), 15)
|
||||
self.assertEqual(len(self.ab_testing_mailing_2.mailing_trace_ids), 30)
|
||||
self.assertEqual(len(unique_recipients_used), 45)
|
||||
|
||||
self.ab_testing_mailing_1.mailing_trace_ids[:10].set_opened()
|
||||
self.ab_testing_mailing_2.mailing_trace_ids[:15].set_opened()
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
|
||||
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 66)
|
||||
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 50)
|
||||
|
||||
with self.mock_mail_gateway():
|
||||
self.ab_testing_mailing_2.action_send_winner_mailing()
|
||||
self.ab_testing_mailing_ids.invalidate_cache()
|
||||
winner_mailing = self.ab_testing_campaign.mailing_mail_ids.filtered(lambda mailing: mailing.ab_testing_pc == 100)
|
||||
self.assertEqual(winner_mailing.subject, 'A/B Testing V2')
|
||||
@@ -14,6 +14,10 @@
|
||||
<separator/>
|
||||
<filter name="filter_sent_date" date="sent_date"/>
|
||||
<separator/>
|
||||
<filter string="A/B Tests" name="filter_ab_test" domain="[('ab_testing_enabled', '=', True)]"/>
|
||||
<filter string="A/B Tests to review" name="filter_ab_test_to_review"
|
||||
domain="[('ab_testing_enabled', '=', True), ('ab_testing_winner_selection', '=', 'manual'), ('ab_testing_completed', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="inactive" string="Archived" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||
@@ -30,19 +34,20 @@
|
||||
<field name="model">mailing.mailing</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Mailings" sample="1" decoration-info="state in ['draft', 'in_queue']">
|
||||
<field name="calendar_date" string="Date" widget="date"/>
|
||||
<tree string="Mailings" sample="1">
|
||||
<field name="calendar_date" string="Date" widget="datetime"/>
|
||||
<field name="subject" attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
|
||||
<field name="mailing_model_id" string="Recipients" optional="hide"/>
|
||||
<field name="user_id" widget="many2one_avatar_user"/>
|
||||
<field name="ab_testing_enabled" string="A/B Test" groups="mass_mailing.group_mass_mailing_campaign"/>
|
||||
<field name="campaign_id" string="Campaign" groups="mass_mailing.group_mass_mailing_campaign" optional="hide"/>
|
||||
<field name="sent"/>
|
||||
<field name="received_ratio" class="d-flex align-items-center pl-0 pl-lg-5" widget="progressbar" string="Delivered (%)"/>
|
||||
<field name="opened_ratio" class="d-flex align-items-center pl-0 pl-lg-5" widget="progressbar" string="Opened (%)"/>
|
||||
<field name="bounced_ratio" string="Bounced (%)"/>
|
||||
<field name="bounced_ratio" string="Bounced (%)" optional="hide"/>
|
||||
<field name="clicks_ratio" string="Clicked (%)"/>
|
||||
<field name="replied_ratio" string="Replied (%)"/>
|
||||
<field name="state" decoration-info="state == 'draft'" decoration-success="state == 'sending' or state == 'done'" widget="badge"/>
|
||||
<field name="state" decoration-info="state in ['draft', 'in_queue']" decoration-success="state == 'sending' or state == 'done'" widget="badge"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -231,22 +236,57 @@
|
||||
<field name="copyvalue"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="A/B Tests" name="ab_testing" groups="mass_mailing.group_mass_mailing_campaign">
|
||||
<group>
|
||||
<group>
|
||||
<label for="ab_testing_enabled"/>
|
||||
<span class="d-flex">
|
||||
<field name="ab_testing_enabled" attrs="{'readonly': [('state', '!=', 'draft')]}" force_save="1"/>
|
||||
<span class="col" attrs="{'invisible': [('ab_testing_enabled', '=', False)]}">
|
||||
on <field name="ab_testing_pc" class="col-6 text-center"
|
||||
attrs="{'readonly': [('state', '!=', 'draft')]}"/> %
|
||||
</span>
|
||||
</span>
|
||||
<field name="ab_testing_winner_selection"
|
||||
attrs="{'required': [('ab_testing_enabled', '=', True), ('mailing_type', '=', 'mail')], 'invisible': ['|', ('ab_testing_enabled', '=', False), ('mailing_type', '!=', 'mail')], 'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="ab_testing_schedule_datetime"
|
||||
attrs="{'required': [('ab_testing_enabled', '=', True), ('ab_testing_winner_selection', '!=', 'manual')], 'readonly': ['|', ('ab_testing_enabled', '=', False), ('state', '!=', 'draft')], 'invisible': ['|', ('ab_testing_enabled', '=', False), ('ab_testing_winner_selection', '=', 'manual')]}"/>
|
||||
</group>
|
||||
<div>
|
||||
<field name="ab_testing_mailings_count" invisible="1"/>
|
||||
<field name="ab_testing_completed" invisible="1"/>
|
||||
<field name="ab_testing_description" nolabel="1"/>
|
||||
<div attrs="{'invisible': ['|', ('ab_testing_mailings_count', '<', 2), ('ab_testing_enabled', '=', False)]}">
|
||||
<button name="action_compare_versions" type="object" class="btn btn-link d-block">
|
||||
<i class="fa fa-bar-chart"/> Compare Version
|
||||
</button>
|
||||
<button name="action_duplicate" type="object" class="btn btn-link d-block" attrs="{'invisible': [('ab_testing_completed', '=', True)]}">
|
||||
<i class="fa fa-copy"/> Create an Alternative
|
||||
</button>
|
||||
<button name="action_send_winner_mailing" type="object" class="btn btn-link d-block" attrs="{'invisible': [('ab_testing_completed', '=', True)]}">
|
||||
<i class="fa fa-envelope"/> <span name="ab_test_manual" attrs="{'invisible': [('ab_testing_winner_selection', '!=', 'manual')]}">
|
||||
Send this version to remaining recipients
|
||||
</span> <span name="ab_test_auto" attrs="{'invisible': [('ab_testing_winner_selection', '=', 'manual')]}">
|
||||
Send Winner Now
|
||||
</span>
|
||||
</button>
|
||||
<button name="action_select_as_winner" type="object" class="btn btn-link d-block"
|
||||
attrs="{'invisible': ['|', ('ab_testing_completed', '!=', False), ('ab_testing_winner_selection', '!=', 'manual')]}">
|
||||
<i class="fa fa-envelope"/> Send this as winner
|
||||
</button>
|
||||
</div>
|
||||
<button name="action_duplicate" type="object" class="btn btn-primary"
|
||||
attrs="{'invisible': ['|', ('ab_testing_mailings_count', '>=', 2), ('ab_testing_enabled', '=', False)]}">
|
||||
Create an Alternative Version
|
||||
</button>
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Settings" name="settings">
|
||||
<group>
|
||||
<group string="Extra Options">
|
||||
<group string="Email Content">
|
||||
<field class="o_text_overflow" name="preview" string="Preview Text" attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}" widget="char_emojis" placeholder="e.g. Check it out before it's too late!"/>
|
||||
<label for="schedule_type" string="Schedule"/>
|
||||
<div name="schedule_block">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<field name="schedule_type"/>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6" attrs="{'invisible': [('schedule_type', '=', 'now')]}">
|
||||
<field name="schedule_date" attrs="{'required': [('schedule_type', '=', 'scheduled')]}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<field name="user_id" domain="[('share', '=', False)]"/>
|
||||
<field name="user_id" widget="many2one_avatar_user" domain="[('share', '=', False)]"/>
|
||||
<field name="email_from" attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
|
||||
<label for="reply_to"/>
|
||||
<div name="reply_to_details">
|
||||
@@ -279,8 +319,7 @@
|
||||
<field name="campaign_id"
|
||||
string="Campaign"
|
||||
groups="mass_mailing.group_mass_mailing_campaign"
|
||||
attrs="{'readonly': [('state', 'in', ('sending', 'done'))],
|
||||
'required': [('unique_ab_testing', '=', True)]}"/>
|
||||
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
|
||||
<field name="medium_id"
|
||||
string="Medium"
|
||||
required="True"
|
||||
@@ -293,15 +332,6 @@
|
||||
class="o_text_overflow"
|
||||
groups="base.group_no_one"
|
||||
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
|
||||
<field name="unique_ab_testing"
|
||||
groups="mass_mailing.group_mass_mailing_campaign"
|
||||
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
|
||||
<label for="contact_ab_pc" groups="mass_mailing.group_mass_mailing_campaign"/>
|
||||
<div name="contact_ab_pc" groups="mass_mailing.group_mass_mailing_campaign">
|
||||
<field name="contact_ab_pc"
|
||||
class="oe_inline"
|
||||
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/> %
|
||||
</div>
|
||||
</group>
|
||||
<group string="Advanced" groups="base.group_no_one">
|
||||
<field name="mail_server_available" invisible="1"/>
|
||||
@@ -626,6 +656,12 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ab_testing_open_winner_mailing" model="ir.actions.act_window">
|
||||
<field name="name">A/B Test Winner</field>
|
||||
<field name="res_model">mailing.mailing</field>
|
||||
<field name="view_mode">form</field>
|
||||
</record>
|
||||
|
||||
<menuitem name="Mailings" id="mass_mailing_menu"
|
||||
parent="mass_mailing_menu_root"
|
||||
sequence="1"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_mass_mailing_campaign"/>
|
||||
<div class="text-muted">
|
||||
Manage mass mailing campaigns
|
||||
Manage campaigns and A/B test your mailings
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import crm_lead
|
||||
from . import mailing_mailing
|
||||
from . import utm
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
_inherit = 'utm.campaign'
|
||||
|
||||
ab_testing_winner_selection = fields.Selection(selection_add=[('crm_lead_count', 'Leads')])
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Mass mailing sms on lead / opportunities',
|
||||
'category': 'Hidden',
|
||||
'version': '1.0',
|
||||
'summary': 'Add lead / opportunities info on mass mailing sms',
|
||||
'description': """Mass mailing sms on lead / opportunities""",
|
||||
'depends': ['mass_mailing_crm', 'mass_mailing_sms'],
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import utm
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
_inherit = 'utm.campaign'
|
||||
|
||||
ab_testing_sms_winner_selection = fields.Selection(selection_add=[('crm_lead_count', 'Leads')])
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import mailing_mailing
|
||||
from . import sale_order
|
||||
from . import utm
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
_inherit = 'utm.campaign'
|
||||
|
||||
ab_testing_winner_selection = fields.Selection(selection_add=[
|
||||
('sale_quotation_count', 'Quotations'),
|
||||
('sale_invoiced_amount', 'Revenues'),
|
||||
])
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Mass mailing sms on sale orders',
|
||||
'category': 'Hidden',
|
||||
'version': '1.0',
|
||||
'summary': 'Add sale order info on mass mailing sms',
|
||||
'description': """Mass mailing sms on sale orders""",
|
||||
'depends': ['mass_mailing_sale', 'mass_mailing_sms'],
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import utm
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
_inherit = 'utm.campaign'
|
||||
|
||||
ab_testing_sms_winner_selection = fields.Selection(selection_add=[
|
||||
('sale_quotation_count', 'Quotations'),
|
||||
('sale_invoiced_amount', 'Revenues'),
|
||||
])
|
||||
@@ -46,6 +46,8 @@ class Mailing(models.Model):
|
||||
'Send Directly', help='Immediately send the SMS Mailing instead of queuing up. Use at your own risk.')
|
||||
# opt_out_link
|
||||
sms_allow_unsubscribe = fields.Boolean('Include opt-out link', default=False)
|
||||
# A/B Testing
|
||||
ab_testing_sms_winner_selection = fields.Selection(related="campaign_id.ab_testing_sms_winner_selection", default="clicks_ratio", readonly=False, copy=True)
|
||||
|
||||
@api.depends('mailing_type')
|
||||
def _compute_medium_id(self):
|
||||
@@ -82,7 +84,6 @@ class Mailing(models.Model):
|
||||
mail.sms_has_insufficient_credit = trace_dict[mail.id]['sms_credit']
|
||||
mail.sms_has_unregistered_account = trace_dict[mail.id]['sms_acc']
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# ORM OVERRIDES
|
||||
# --------------------------------------------------
|
||||
@@ -318,3 +319,47 @@ class Mailing(models.Model):
|
||||
res[mailing.id] = body
|
||||
res.update(super(Mailing, self - sms_mailings).convert_links())
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------
|
||||
# A/B Test Override
|
||||
# ------------------------------------------------------
|
||||
|
||||
def _get_ab_testing_description_modifying_fields(self):
|
||||
fields_list = super()._get_ab_testing_description_modifying_fields()
|
||||
return fields_list + ['ab_testing_sms_winner_selection']
|
||||
|
||||
def _get_ab_testing_description_values(self):
|
||||
values = super()._get_ab_testing_description_values()
|
||||
if self.mailing_type == 'sms':
|
||||
values.update({
|
||||
'ab_testing_winner_selection': self.ab_testing_sms_winner_selection,
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_ab_testing_winner_selection(self):
|
||||
result = super()._get_ab_testing_winner_selection()
|
||||
if self.mailing_type == 'sms':
|
||||
ab_testing_winner_selection_description = dict(
|
||||
self._fields.get('ab_testing_sms_winner_selection').related_field.selection
|
||||
).get(self.ab_testing_sms_winner_selection)
|
||||
result.update({
|
||||
'value': self.campaign_id.ab_testing_sms_winner_selection,
|
||||
'description': ab_testing_winner_selection_description
|
||||
})
|
||||
return result
|
||||
|
||||
def _get_ab_testing_siblings_mailings(self):
|
||||
mailings = super()._get_ab_testing_siblings_mailings()
|
||||
if self.mailing_type == 'sms':
|
||||
mailings = self.campaign_id.mailing_sms_ids.filtered('ab_testing_enabled')
|
||||
return mailings
|
||||
|
||||
def _get_default_ab_testing_campaign_values(self, values=None):
|
||||
campaign_values = super()._get_default_ab_testing_campaign_values(values)
|
||||
values = values or dict()
|
||||
if self.mailing_type == 'sms':
|
||||
sms_subject = values.get('sms_subject') or self.sms_subject
|
||||
if sms_subject:
|
||||
campaign_values['name'] = _("A/B Test: %s", sms_subject)
|
||||
campaign_values['ab_testing_sms_winner_selection'] = self.ab_testing_sms_winner_selection
|
||||
return campaign_values
|
||||
|
||||
@@ -13,6 +13,19 @@ class UtmCampaign(models.Model):
|
||||
string='Mass SMS')
|
||||
mailing_sms_count = fields.Integer('Number of Mass SMS', compute="_compute_mailing_sms_count")
|
||||
|
||||
# A/B Testing
|
||||
ab_testing_sms_winner_selection = fields.Selection([
|
||||
('manual', 'Manual'),
|
||||
('clicks_ratio', 'Highest Click Rate')], string="SMS Winner Selection", default="clicks_ratio")
|
||||
|
||||
@api.depends('mailing_mail_ids', 'mailing_sms_ids')
|
||||
def _compute_ab_testing_total_pc(self):
|
||||
super()._compute_ab_testing_total_pc()
|
||||
for campaign in self:
|
||||
campaign.ab_testing_total_pc += sum([
|
||||
mailing.ab_testing_pc for mailing in campaign.mailing_sms_ids.filtered('ab_testing_enabled')
|
||||
])
|
||||
|
||||
@api.depends('mailing_sms_ids')
|
||||
def _compute_mailing_sms_count(self):
|
||||
for campaign in self:
|
||||
@@ -40,3 +53,13 @@ class UtmCampaign(models.Model):
|
||||
}
|
||||
action['domain'] = [('mailing_type', '=', 'sms')]
|
||||
return action
|
||||
|
||||
@api.model
|
||||
def _cron_process_mass_mailing_ab_testing(self):
|
||||
ab_testing_campaign = super()._cron_process_mass_mailing_ab_testing()
|
||||
for campaign in ab_testing_campaign:
|
||||
ab_testing_mailings = campaign.mailing_sms_ids.filtered(lambda m: m.ab_testing_enabled)
|
||||
if not ab_testing_mailings.filtered(lambda m: m.state == 'done'):
|
||||
continue
|
||||
ab_testing_mailings.action_send_winner_mailing()
|
||||
return ab_testing_campaign
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<attribute name="attrs">{'invisible': ['|', ('mailing_type', '!=', 'mail'), ('state', 'in', ('in_queue', 'sending', 'done'))]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='action_schedule']" position="before">
|
||||
<field name="sms_force_send" invisible="1"/>
|
||||
<field name="schedule_type" invisible="1"/>
|
||||
<button name="action_put_in_queue" type="object"
|
||||
string="Send" class="oe_highlight" data-hotkey="v"
|
||||
attrs="{'invisible': ['|', '|', ('mailing_type', '=', 'mail'), ('state', 'in', ('in_queue', 'sending', 'done')), ('sms_force_send', '=', True)]}"
|
||||
@@ -32,12 +34,6 @@
|
||||
attrs="{'invisible': ['|', '|', '|', ('mailing_type', '=', 'mail'), ('state', 'in', ('in_queue', 'done')), ('schedule_type', '=', 'scheduled'), ('sms_force_send', '!=', True)]}"
|
||||
confirm="This will send SMS to all recipients now. Do you still want to proceed ?"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='schedule_block']/div[hasclass('row')]" position="inside">
|
||||
<div attrs="{'invisible': ['|', '|', ('mailing_type', '=', 'mail'), ('state', 'in', ['in_queue', 'sending', 'done']), ('schedule_type', '=', 'scheduled')]}">
|
||||
<label for="sms_force_send" string="Skip Queue"/>
|
||||
<field name="sms_force_send"/>
|
||||
</div>
|
||||
</xpath>
|
||||
<!-- Headers / Warnings -->
|
||||
<xpath expr="//header" position="after">
|
||||
<field name="sms_has_insufficient_credit" invisible="1"/>
|
||||
@@ -154,14 +150,29 @@
|
||||
<attribute name="attrs">{'invisible': ['|', ('mailing_type', '!=', 'mail'),
|
||||
('mail_server_available', '=', False)], 'readonly': [('state', 'in', ('sending', 'done'))]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='unique_ab_testing']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
|
||||
<!-- A/B Testing -->
|
||||
<xpath expr="//field[@name='ab_testing_winner_selection']" position="after">
|
||||
<label for="ab_testing_sms_winner_selection" string="Winner Selection"
|
||||
attrs="{'invisible': ['|', ('ab_testing_enabled', '=', False), ('mailing_type', '!=', 'sms')]}"/>
|
||||
<field name="ab_testing_sms_winner_selection" nolabel="1"
|
||||
attrs="{'required': [('ab_testing_enabled', '=', True), ('mailing_type', '=', 'sms')], 'invisible': ['|', ('ab_testing_enabled', '=', False), ('mailing_type', '!=', 'sms')], 'readonly': [('state', '!=', 'draft')]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='contact_ab_pc']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
|
||||
<xpath expr="//field[@name='ab_testing_schedule_datetime']" position="replace">
|
||||
<field name="ab_testing_schedule_datetime"
|
||||
attrs="{'required': [('ab_testing_enabled', '=', True), ('ab_testing_winner_selection', '!=', 'manual'), ('ab_testing_sms_winner_selection', '!=', 'manual')], 'readonly': ['|', ('ab_testing_enabled', '=', False), ('state', '!=', 'draft')], 'invisible': ['|', '|', ('ab_testing_enabled', '=', False), ('ab_testing_winner_selection', '=', 'manual'), ('ab_testing_sms_winner_selection', '=', 'manual')]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//label[@for='contact_ab_pc']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
|
||||
<xpath expr="//span[@name='ab_test_manual']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': ['|', ('ab_testing_winner_selection', '!=', 'manual'),
|
||||
('ab_testing_sms_winner_selection', '!=', 'manual')]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='ab_test_auto']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('ab_testing_winner_selection', '=', 'manual'),
|
||||
('ab_testing_sms_winner_selection', '=', 'manual')]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='action_select_as_winner']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': ['|', ('ab_testing_completed', '!=', False), '|',
|
||||
('ab_testing_winner_selection', '=', 'manual'),
|
||||
('ab_testing_sms_winner_selection', '=', 'manual')]}</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
Reference in New Issue
Block a user