[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:
Jérémy Hennecart
2021-09-07 18:08:03 +00:00
parent 50b21cd436
commit bc8f4373c3
26 changed files with 707 additions and 69 deletions
@@ -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;">
+13 -1
View File
@@ -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>
+191 -20
View File
@@ -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
+62 -5
View File
@@ -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 });
},
+1
View File
@@ -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
+12
View File
@@ -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', '&lt;', 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', '&gt;=', 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
+10
View File
@@ -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')])
+4
View File
@@ -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
+10
View File
@@ -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
+13
View File
@@ -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'),
])
+4
View File
@@ -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
+23
View File
@@ -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>