Initial commit: Mail packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 4e53507711
1948 changed files with 751201 additions and 0 deletions

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_http
from . import ir_mail_server
from . import ir_model
from . import link_tracker
from . import mailing_contact_subscription
from . import mailing_contact
from . import mailing_list
from . import mailing_trace
from . import mailing
from . import mailing_filter
from . import mail_mail
from . import mail_render_mixin
from . import mail_thread
from . import res_company
from . import res_config_settings
from . import res_partner
from . import res_users
from . import utm_campaign
from . import utm_medium
from . import utm_source

View file

@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrHttp(models.AbstractModel):
_inherit = "ir.http"
@classmethod
def _get_translation_frontend_modules_name(cls):
mods = super()._get_translation_frontend_modules_name()
return mods + ["mass_mailing"]

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
from odoo.tools.misc import format_date
class IrMailServer(models.Model):
_name = 'ir.mail_server'
_inherit = ['ir.mail_server']
active_mailing_ids = fields.One2many(
comodel_name='mailing.mailing',
inverse_name='mail_server_id',
string='Active mailing using this mail server',
readonly=True,
domain=[('state', '!=', 'done'), ('active', '=', True)])
def _active_usages_compute(self):
def format_usage(mailing_id):
base = _('Mass Mailing "%s"', mailing_id.display_name)
if not mailing_id.schedule_date:
return base
details = _('(scheduled for %s)', format_date(self.env, mailing_id.schedule_date))
return f'{base} {details}'
usages_super = super(IrMailServer, self)._active_usages_compute()
default_mail_server_id = self.env['mailing.mailing']._get_default_mail_server_id()
for record in self:
usages = []
if default_mail_server_id == record.id:
usages.append(_('Email Marketing uses it as its default mail server to send mass mailings'))
usages.extend(map(format_usage, record.active_mailing_ids))
if usages:
usages_super.setdefault(record.id, []).extend(usages)
return usages_super

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
class IrModel(models.Model):
_inherit = 'ir.model'
is_mailing_enabled = fields.Boolean(
string="Mailing Enabled",
compute='_compute_is_mailing_enabled', search='_search_is_mailing_enabled',
help="Whether this model supports marketing mailing capabilities (notably email and SMS).",
)
def _compute_is_mailing_enabled(self):
for model in self:
model.is_mailing_enabled = getattr(self.env[model.model], '_mailing_enabled', False)
def _search_is_mailing_enabled(self, operator, value):
if operator not in ('=', '!='):
raise ValueError(_("Searching Mailing Enabled models supports only direct search using '='' or '!='."))
valid_models = self.env['ir.model']
for model in self.search([]):
if model.model not in self.env or model.is_transient():
continue
if getattr(self.env[model.model], '_mailing_enabled', False):
valid_models |= model
search_is_mailing_enabled = (operator == '=' and value) or (operator == '!=' and not value)
if search_is_mailing_enabled:
return [('id', 'in', valid_models.ids)]
return [('id', 'not in', valid_models.ids)]

View file

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class LinkTracker(models.Model):
_inherit = "link.tracker"
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing')
class LinkTrackerClick(models.Model):
_inherit = "link.tracker.click"
mailing_trace_id = fields.Many2one('mailing.trace', string='Mail Statistics')
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing')
def _prepare_click_values_from_route(self, **route_values):
click_values = super(LinkTrackerClick, self)._prepare_click_values_from_route(**route_values)
if click_values.get('mailing_trace_id'):
trace_sudo = self.env['mailing.trace'].sudo().browse(route_values['mailing_trace_id']).exists()
if not trace_sudo:
click_values['mailing_trace_id'] = False
else:
if not click_values.get('campaign_id'):
click_values['campaign_id'] = trace_sudo.campaign_id.id
if not click_values.get('mass_mailing_id'):
click_values['mass_mailing_id'] = trace_sudo.mass_mailing_id.id
return click_values
@api.model
def add_click(self, code, **route_values):
click = super(LinkTrackerClick, self).add_click(code, **route_values)
if click and click.mailing_trace_id:
click.mailing_trace_id.set_opened()
click.mailing_trace_id.set_clicked()
return click

View file

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import werkzeug.urls
from odoo import api, fields, models, tools
class MailMail(models.Model):
"""Add the mass mailing campaign data to mail"""
_inherit = ['mail.mail']
mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing')
mailing_trace_ids = fields.One2many('mailing.trace', 'mail_mail_id', string='Statistics')
def _get_tracking_url(self):
token = tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', self.id)
return werkzeug.urls.url_join(self.get_base_url(), 'mail/track/%s/%s/blank.gif' % (self.id, token))
def _send_prepare_body(self):
""" Override to add the tracking URL to the body and to add
trace ID in shortened urls """
# TDE: temporary addition (mail was parameter) due to semi-new-API
self.ensure_one()
body = super(MailMail, self)._send_prepare_body()
if self.mailing_id and body and self.mailing_trace_ids:
for match in set(re.findall(tools.URL_REGEX, self.body_html)):
href = match[0]
url = match[1]
parsed = werkzeug.urls.url_parse(url, scheme='http')
if parsed.scheme.startswith('http') and parsed.path.startswith('/r/'):
new_href = href.replace(url, url + '/m/' + str(self.mailing_trace_ids[0].id))
body = body.replace(href, new_href)
# generate tracking URL
tracking_url = self._get_tracking_url()
body = tools.append_content_to_html(
body,
'<img src="%s"/>' % tracking_url,
plaintext=False,
)
body = self.env['mail.render.mixin']._replace_local_links(body)
return body
def _send_prepare_values(self, partner=None):
# TDE: temporary addition (mail was parameter) due to semi-new-API
res = super(MailMail, self)._send_prepare_values(partner)
if self.mailing_id and res.get('email_to'):
base_url = self.mailing_id.get_base_url()
emails = tools.email_split(res.get('email_to')[0])
email_to = emails and emails[0] or False
unsubscribe_url = self.mailing_id._get_unsubscribe_url(email_to, self.res_id)
unsubscribe_oneclick_url = self.mailing_id._get_unsubscribe_oneclick_url(email_to, self.res_id)
view_url = self.mailing_id._get_view_url(email_to, self.res_id)
# replace links in body
if not tools.is_html_empty(res.get('body')):
if f'{base_url}/unsubscribe_from_list' in res['body']:
res['body'] = res['body'].replace(
f'{base_url}/unsubscribe_from_list',
unsubscribe_url,
)
if f'{base_url}/view' in res.get('body'):
res['body'] = res['body'].replace(
f'{base_url}/view',
view_url,
)
# add headers
res.setdefault("headers", {}).update({
'List-Unsubscribe': f'<{unsubscribe_oneclick_url}>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'Precedence': 'list',
'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange
})
return res
def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None):
mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed
for mail in self:
if mail.mailing_id:
if mail_sent is True and mail.mailing_trace_ids:
mail.mailing_trace_ids.set_sent()
elif mail_sent is False and mail.mailing_trace_ids:
mail.mailing_trace_ids.set_failed(failure_type=failure_type)
return super(MailMail, self)._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type)

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class MailRenderMixin(models.AbstractModel):
_inherit = "mail.render.mixin"
@api.model
def _render_template_postprocess(self, rendered):
# super will transform relative url to absolute
rendered = super(MailRenderMixin, self)._render_template_postprocess(rendered)
# apply shortener after
if self.env.context.get('post_convert_links'):
for res_id, html in rendered.items():
rendered[res_id] = self._shorten_links(
html,
self.env.context['post_convert_links'],
blacklist=['/unsubscribe_from_list', '/view']
)
return rendered

View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from odoo import api, models, fields, tools
BLACKLIST_MAX_BOUNCED_LIMIT = 5
class MailThread(models.AbstractModel):
""" Update MailThread to add the support of bounce management in mass mailing traces. """
_inherit = 'mail.thread'
@api.model
def _message_route_process(self, message, message_dict, routes):
""" Override to update the parent mailing traces. The parent is found
by using the References header of the incoming message and looking for
matching message_id in mailing.trace. """
if routes:
# even if 'reply_to' in ref (cfr mail/mail_thread) that indicates a new thread redirection
# (aka bypass alias configuration in gateway) consider it as a reply for statistics purpose
thread_references = message_dict['references'] or message_dict['in_reply_to']
msg_references = tools.mail_header_msgid_re.findall(thread_references)
if msg_references:
self.env['mailing.trace'].set_opened(domain=[('message_id', 'in', msg_references)])
self.env['mailing.trace'].set_replied(domain=[('message_id', 'in', msg_references)])
return super(MailThread, self)._message_route_process(message, message_dict, routes)
def message_post_with_template(self, template_id, **kwargs):
# avoid having message send through `message_post*` methods being implicitly considered as
# mass-mailing
no_massmail = self.with_context(
default_mass_mailing_name=False,
default_mass_mailing_id=False,
)
return super(MailThread, no_massmail).message_post_with_template(template_id, **kwargs)
@api.model
def _routing_handle_bounce(self, email_message, message_dict):
""" In addition, an auto blacklist rule check if the email can be blacklisted
to avoid sending mails indefinitely to this email address.
This rule checks if the email bounced too much. If this is the case,
the email address is added to the blacklist in order to avoid continuing
to send mass_mail to that email address. If it bounced too much times
in the last month and the bounced are at least separated by one week,
to avoid blacklist someone because of a temporary mail server error,
then the email is considered as invalid and is blacklisted."""
super(MailThread, self)._routing_handle_bounce(email_message, message_dict)
bounced_email = message_dict['bounced_email']
bounced_msg_id = message_dict['bounced_msg_id']
bounced_partner = message_dict['bounced_partner']
if bounced_msg_id:
self.env['mailing.trace'].set_bounced(domain=[('message_id', 'in', bounced_msg_id)])
if bounced_email:
three_months_ago = fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(weeks=13))
stats = self.env['mailing.trace'].search(['&', '&', ('trace_status', '=', 'bounce'), ('write_date', '>', three_months_ago), ('email', '=ilike', bounced_email)]).mapped('write_date')
if len(stats) >= BLACKLIST_MAX_BOUNCED_LIMIT and (not bounced_partner or any(p.message_bounce >= BLACKLIST_MAX_BOUNCED_LIMIT for p in bounced_partner)):
if max(stats) > min(stats) + datetime.timedelta(weeks=1):
blacklist_rec = self.env['mail.blacklist'].sudo()._add(bounced_email)
blacklist_rec._message_log(
body='This email has been automatically blacklisted because of too much bounced.')
@api.model
def message_new(self, msg_dict, custom_values=None):
""" Overrides mail_thread message_new that is called by the mailgateway
through message_process.
This override updates the document according to the email.
"""
defaults = {}
if isinstance(self, self.pool['utm.mixin']):
thread_references = msg_dict.get('references', '') or msg_dict.get('in_reply_to', '')
msg_references = tools.mail_header_msgid_re.findall(thread_references)
if msg_references:
traces = self.env['mailing.trace'].search([('message_id', 'in', msg_references)], limit=1)
if traces:
defaults['campaign_id'] = traces.campaign_id.id
defaults['source_id'] = traces.mass_mailing_id.source_id.id
defaults['medium_id'] = traces.mass_mailing_id.medium_id.id
if custom_values:
defaults.update(custom_values)
return super(MailThread, self).message_new(msg_dict, custom_values=defaults)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError
from odoo.osv import expression
class MassMailingContactListRel(models.Model):
""" Intermediate model between mass mailing list and mass mailing contact
Indicates if a contact is opted out for a particular list
"""
_name = 'mailing.contact.subscription'
_description = 'Mass Mailing Subscription Information'
_table = 'mailing_contact_list_rel'
_rec_name = 'contact_id'
contact_id = fields.Many2one('mailing.contact', string='Contact', ondelete='cascade', required=True)
list_id = fields.Many2one('mailing.list', string='Mailing List', ondelete='cascade', required=True)
opt_out = fields.Boolean(string='Opt Out',
help='The contact has chosen not to receive mails anymore from this list', default=False)
unsubscription_date = fields.Datetime(string='Unsubscription Date')
message_bounce = fields.Integer(related='contact_id.message_bounce', store=False, readonly=False)
is_blacklisted = fields.Boolean(related='contact_id.is_blacklisted', store=False, readonly=False)
_sql_constraints = [
('unique_contact_list', 'unique (contact_id, list_id)',
'A mailing contact cannot subscribe to the same mailing list multiple times.')
]
@api.model_create_multi
def create(self, vals_list):
now = fields.Datetime.now()
for vals in vals_list:
if 'opt_out' in vals and not vals.get('unsubscription_date'):
vals['unsubscription_date'] = now if vals['opt_out'] else False
if vals.get('unsubscription_date'):
vals['opt_out'] = True
return super().create(vals_list)
def write(self, vals):
if 'opt_out' in vals and 'unsubscription_date' not in vals:
vals['unsubscription_date'] = fields.Datetime.now() if vals['opt_out'] else False
if vals.get('unsubscription_date'):
vals['opt_out'] = True
return super(MassMailingContactListRel, self).write(vals)
class MassMailingContact(models.Model):
"""Model of a contact. This model is different from the partner model
because it holds only some basic information: name, email. The purpose is to
be able to deal with large contact list to email without bloating the partner
base."""
_name = 'mailing.contact'
_inherit = ['mail.thread.blacklist']
_description = 'Mailing Contact'
_order = 'email'
_mailing_enabled = True
def default_get(self, fields_list):
""" When coming from a mailing list we may have a default_list_ids context
key. We should use it to create subscription_list_ids default value that
are displayed to the user as list_ids is not displayed on form view. """
res = super(MassMailingContact, self).default_get(fields_list)
if 'subscription_list_ids' in fields_list and not res.get('subscription_list_ids'):
list_ids = self.env.context.get('default_list_ids')
if 'default_list_ids' not in res and list_ids and isinstance(list_ids, (list, tuple)):
res['subscription_list_ids'] = [
(0, 0, {'list_id': list_id}) for list_id in list_ids]
return res
name = fields.Char()
company_name = fields.Char(string='Company Name')
title_id = fields.Many2one('res.partner.title', string='Title')
email = fields.Char('Email')
list_ids = fields.Many2many(
'mailing.list', 'mailing_contact_list_rel',
'contact_id', 'list_id', string='Mailing Lists')
subscription_list_ids = fields.One2many(
'mailing.contact.subscription', 'contact_id', string='Subscription Information')
country_id = fields.Many2one('res.country', string='Country')
tag_ids = fields.Many2many('res.partner.category', string='Tags')
opt_out = fields.Boolean(
'Opt Out',
compute='_compute_opt_out', search='_search_opt_out',
help='Opt out flag for a specific mailing list. '
'This field should not be used in a view without a unique and active mailing list context.')
@api.model
def _search_opt_out(self, operator, value):
# Assumes operator is '=' or '!=' and value is True or False
if operator != '=':
if operator == '!=' and isinstance(value, bool):
value = not value
else:
raise NotImplementedError()
if 'default_list_ids' in self._context and isinstance(self._context['default_list_ids'], (list, tuple)) and len(self._context['default_list_ids']) == 1:
[active_list_id] = self._context['default_list_ids']
contacts = self.env['mailing.contact.subscription'].search([('list_id', '=', active_list_id)])
return [('id', 'in', [record.contact_id.id for record in contacts if record.opt_out == value])]
return expression.FALSE_DOMAIN if value else expression.TRUE_DOMAIN
@api.depends('subscription_list_ids')
@api.depends_context('default_list_ids')
def _compute_opt_out(self):
if 'default_list_ids' in self._context and isinstance(self._context['default_list_ids'], (list, tuple)) and len(self._context['default_list_ids']) == 1:
[active_list_id] = self._context['default_list_ids']
for record in self:
active_subscription_list = record.subscription_list_ids.filtered(lambda l: l.list_id.id == active_list_id)
record.opt_out = active_subscription_list.opt_out
else:
for record in self:
record.opt_out = False
def get_name_email(self, name):
name, email = self.env['res.partner']._parse_partner_name(name)
if name and not email:
email = name
if email and not name:
name = email
return name, email
@api.model_create_multi
def create(self, vals_list):
""" Synchronize default_list_ids (currently used notably for computed
fields) default key with subscription_list_ids given by user when creating
contacts.
Those two values have the same purpose, adding a list to to the contact
either through a direct write on m2m, either through a write on middle
model subscription.
This is a bit hackish but is due to default_list_ids key being
used to compute oupt_out field. This should be cleaned in master but here
we simply try to limit issues while keeping current behavior. """
default_list_ids = self._context.get('default_list_ids')
default_list_ids = default_list_ids if isinstance(default_list_ids, (list, tuple)) else []
for vals in vals_list:
if vals.get('list_ids') and vals.get('subscription_list_ids'):
raise UserError(_('You should give either list_ids, either subscription_list_ids to create new contacts.'))
if default_list_ids:
for vals in vals_list:
if vals.get('list_ids'):
continue
current_list_ids = []
subscription_ids = vals.get('subscription_list_ids') or []
for subscription in subscription_ids:
if len(subscription) == 3:
current_list_ids.append(subscription[2]['list_id'])
for list_id in set(default_list_ids) - set(current_list_ids):
subscription_ids.append((0, 0, {'list_id': list_id}))
vals['subscription_list_ids'] = subscription_ids
return super(MassMailingContact, self.with_context(default_list_ids=False)).create(vals_list)
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
""" Cleans the default_list_ids while duplicating mailing contact in context of
a mailing list because we already have subscription lists copied over for newly
created contact, no need to add the ones from default_list_ids again """
if self.env.context.get('default_list_ids'):
self = self.with_context(default_list_ids=False)
return super().copy(default)
@api.model
def name_create(self, name):
name, email = self.get_name_email(name)
contact = self.create({'name': name, 'email': email})
return contact.name_get()[0]
@api.model
def add_to_list(self, name, list_id):
name, email = self.get_name_email(name)
contact = self.create({'name': name, 'email': email, 'list_ids': [(4, list_id)]})
return contact.name_get()[0]
def _message_get_default_recipients(self):
return {
r.id: {
'partner_ids': [],
'email_to': ','.join(tools.email_normalize_all(r.email)) or r.email,
'email_cc': False,
} for r in self
}
def action_add_to_mailing_list(self):
ctx = dict(self.env.context, default_contact_ids=self.ids)
action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.mailing_contact_to_list_action")
action['view_mode'] = 'form'
action['target'] = 'new'
action['context'] = ctx
return action
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Mailing List Contacts'),
'template': '/mass_mailing/static/xls/mailing_contact.xls'
}]

View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class MassMailingContactListRel(models.Model):
""" Intermediate model between mass mailing list and mass mailing contact
Indicates if a contact is opted out for a particular list
"""
_name = 'mailing.contact.subscription'
_description = 'Mass Mailing Subscription Information'
_table = 'mailing_contact_list_rel'
_rec_name = 'contact_id'
_order = 'list_id DESC, contact_id DESC'
contact_id = fields.Many2one('mailing.contact', string='Contact', ondelete='cascade', required=True)
list_id = fields.Many2one('mailing.list', string='Mailing List', ondelete='cascade', required=True)
opt_out = fields.Boolean(
string='Opt Out',
default=False,
help='The contact has chosen not to receive mails anymore from this list')
unsubscription_date = fields.Datetime(string='Unsubscription Date')
message_bounce = fields.Integer(related='contact_id.message_bounce', store=False, readonly=False)
is_blacklisted = fields.Boolean(related='contact_id.is_blacklisted', store=False, readonly=False)
_sql_constraints = [
('unique_contact_list', 'unique (contact_id, list_id)',
'A mailing contact cannot subscribe to the same mailing list multiple times.')
]
@api.model_create_multi
def create(self, vals_list):
now = fields.Datetime.now()
for vals in vals_list:
if 'opt_out' in vals and 'unsubscription_date' not in vals:
vals['unsubscription_date'] = now if vals['opt_out'] else False
if vals.get('unsubscription_date'):
vals['opt_out'] = True
return super().create(vals_list)
def write(self, vals):
if 'opt_out' in vals and 'unsubscription_date' not in vals:
vals['unsubscription_date'] = fields.Datetime.now() if vals['opt_out'] else False
if vals.get('unsubscription_date'):
vals['opt_out'] = True
return super(MassMailingContactListRel, self).write(vals)

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class MailingFilter(models.Model):
""" This model stores mass mailing or marketing campaign domain as filters
(quite similar to 'ir.filters' but dedicated to mailing apps). Frequently
used domains can be reused easily. """
_name = 'mailing.filter'
_description = 'Mailing Favorite Filters'
_order = 'create_date DESC'
# override create_uid field to display default value while creating filter from 'Configuration' menus
create_uid = fields.Many2one('res.users', 'Saved by', index=True, readonly=True, default=lambda self: self.env.user)
name = fields.Char(string='Filter Name', required=True)
mailing_domain = fields.Char(string='Filter Domain', required=True)
mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', required=True, ondelete='cascade')
mailing_model_name = fields.Char(string='Recipients Model Name', related='mailing_model_id.model')
@api.constrains('mailing_domain', 'mailing_model_id')
def _check_mailing_domain(self):
""" Check that if the mailing domain is set, it is a valid one """
for mailing_filter in self:
if mailing_filter.mailing_domain != "[]":
try:
self.env[mailing_filter.mailing_model_id.model].search_count(literal_eval(mailing_filter.mailing_domain))
except:
raise ValidationError(
_("The filter domain is not valid for this recipients.")
)

View file

@ -0,0 +1,361 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, Command, fields, models
from odoo.exceptions import UserError
class MassMailingList(models.Model):
"""Model of a contact list. """
_name = 'mailing.list'
_order = 'name'
_description = 'Mailing List'
_mailing_enabled = True
_order = 'create_date DESC'
# As this model has their own data merge, avoid to enable the generic data_merge on that model.
_disable_data_merge = True
name = fields.Char(string='Mailing List', required=True)
active = fields.Boolean(default=True)
contact_count = fields.Integer(compute="_compute_mailing_list_statistics", string='Number of Contacts')
contact_count_email = fields.Integer(compute="_compute_mailing_list_statistics", string="Number of Emails")
contact_count_opt_out = fields.Integer(compute="_compute_mailing_list_statistics", string="Number of Opted-out")
contact_pct_opt_out = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Opted-out")
contact_count_blacklisted = fields.Integer(compute="_compute_mailing_list_statistics", string="Number of Blacklisted")
contact_pct_blacklisted = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Blacklisted")
contact_pct_bounce = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Bouncing")
contact_ids = fields.Many2many(
'mailing.contact', 'mailing_contact_list_rel', 'list_id', 'contact_id',
string='Mailing Lists', copy=False)
mailing_count = fields.Integer(compute="_compute_mailing_list_count", string="Number of Mailing")
mailing_ids = fields.Many2many(
'mailing.mailing', 'mail_mass_mailing_list_rel',
string='Mass Mailings', copy=False)
subscription_ids = fields.One2many(
'mailing.contact.subscription', 'list_id',
string='Subscription Information',
copy=True, depends=['contact_ids'])
is_public = fields.Boolean(
string='Show In Preferences', default=True,
help='The mailing list can be accessible by recipients in the subscription '
'management page to allows them to update their preferences.')
# ------------------------------------------------------
# COMPUTE / ONCHANGE
# ------------------------------------------------------
def _compute_mailing_list_count(self):
data = {}
if self.ids:
self.env.cr.execute('''
SELECT mailing_list_id, count(*)
FROM mail_mass_mailing_list_rel
WHERE mailing_list_id IN %s
GROUP BY mailing_list_id''', (tuple(self.ids),))
data = dict(self.env.cr.fetchall())
for mailing_list in self:
mailing_list.mailing_count = data.get(mailing_list._origin.id, 0)
def _compute_mailing_list_statistics(self):
""" Computes various statistics for this mailing.list that allow users
to have a global idea of its quality (based on blacklist, opt-outs, ...).
As some fields depend on the value of each other (mainly percentages),
we compute everything in a single method. """
# 1. Fetch contact data and associated counts (total / blacklist / opt-out)
contact_statistics_per_mailing = self._fetch_contact_statistics()
# 2. Fetch bounce data
# Optimized SQL way of fetching the count of contacts that have
# at least 1 message bouncing for passed mailing.lists """
bounce_per_mailing = {}
if self.ids:
sql = '''
SELECT mclr.list_id, COUNT(DISTINCT mc.id)
FROM mailing_contact mc
LEFT OUTER JOIN mailing_contact_list_rel mclr
ON mc.id = mclr.contact_id
WHERE mc.message_bounce > 0
AND mclr.list_id in %s
GROUP BY mclr.list_id
'''
self.env.cr.execute(sql, (tuple(self.ids),))
bounce_per_mailing = dict(self.env.cr.fetchall())
# 3. Compute and assign all counts / pct fields
for mailing_list in self:
contact_counts = contact_statistics_per_mailing.get(mailing_list.id, {})
for field, value in contact_counts.items():
if field in self._fields:
mailing_list[field] = value
if mailing_list.contact_count != 0:
mailing_list.contact_pct_opt_out = 100 * (mailing_list.contact_count_opt_out / mailing_list.contact_count)
mailing_list.contact_pct_blacklisted = 100 * (mailing_list.contact_count_blacklisted / mailing_list.contact_count)
mailing_list.contact_pct_bounce = 100 * (bounce_per_mailing.get(mailing_list.id, 0) / mailing_list.contact_count)
else:
mailing_list.contact_pct_opt_out = 0
mailing_list.contact_pct_blacklisted = 0
mailing_list.contact_pct_bounce = 0
# ------------------------------------------------------
# ORM overrides
# ------------------------------------------------------
def write(self, vals):
# Prevent archiving used mailing list
if 'active' in vals and not vals.get('active'):
mass_mailings = self.env['mailing.mailing'].search_count([
('state', '!=', 'done'),
('contact_list_ids', 'in', self.ids),
])
if mass_mailings > 0:
raise UserError(_("At least one of the mailing list you are trying to archive is used in an ongoing mailing campaign."))
return super(MassMailingList, self).write(vals)
def name_get(self):
return [(list.id, "%s (%s)" % (list.name, list.contact_count)) for list in self]
def copy(self, default=None):
self.ensure_one()
default = dict(default or {},
name=_('%s (copy)', self.name),)
return super(MassMailingList, self).copy(default)
# ------------------------------------------------------
# ACTIONS
# ------------------------------------------------------
def action_open_import(self):
"""Open the mailing list contact import wizard."""
action = self.env['ir.actions.actions']._for_xml_id('mass_mailing.mailing_contact_import_action')
action['context'] = {
**self.env.context,
'default_mailing_list_ids': self.ids,
'default_subscription_list_ids': [
Command.create({'list_id': mailing_list.id})
for mailing_list in self
],
}
return action
def action_send_mailing(self):
"""Open the mailing form view, with the current lists set as recipients."""
view = self.env.ref('mass_mailing.mailing_mailing_view_form_full_width')
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.mailing_mailing_action_mail')
action.update({
'context': {
**self.env.context,
'default_contact_list_ids': self.ids,
},
'target': 'current',
'view_type': 'form',
'views': [(view.id, 'form')],
})
return action
def action_view_contacts(self):
action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.action_view_mass_mailing_contacts")
action['domain'] = [('list_ids', 'in', self.ids)]
action['context'] = {'default_list_ids': self.ids}
return action
def action_view_contacts_email(self):
action = self.action_view_contacts()
action['context'] = dict(action.get('context', {}), search_default_filter_valid_email_recipient=1)
return action
def action_view_mailings(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.mailing_mailing_action_mail')
action['domain'] = [('contact_list_ids', 'in', self.ids)]
action['context'] = {'default_mailing_type': 'mail', 'default_contact_list_ids': self.ids}
return action
def action_view_contacts_opt_out(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
action['domain'] = [('list_ids', 'in', self.id)]
action['context'] = {'default_list_ids': self.ids, 'create': False, 'search_default_filter_opt_out': 1}
return action
def action_view_contacts_blacklisted(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
action['domain'] = [('list_ids', 'in', self.id)]
action['context'] = {'default_list_ids': self.ids, 'create': False, 'search_default_filter_blacklisted': 1}
return action
def action_view_contacts_bouncing(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
action['domain'] = [('list_ids', 'in', self.id)]
action['context'] = {'default_list_ids': self.ids, 'create': False, 'search_default_filter_bounce': 1}
return action
def action_merge(self, src_lists, archive):
"""
Insert all the contact from the mailing lists 'src_lists' to the
mailing list in 'self'. Possibility to archive the mailing lists
'src_lists' after the merge except the destination mailing list 'self'.
"""
# Explation of the SQL query with an example. There are the following lists
# A (id=4): yti@odoo.com; yti@example.com
# B (id=5): yti@odoo.com; yti@openerp.com
# C (id=6): nothing
# To merge the mailing lists A and B into C, we build the view st that looks
# like this with our example:
#
# contact_id | email | row_number | list_id |
# ------------+---------------------------+------------------------
# 4 | yti@odoo.com | 1 | 4 |
# 6 | yti@odoo.com | 2 | 5 |
# 5 | yti@example.com | 1 | 4 |
# 7 | yti@openerp.com | 1 | 5 |
#
# The row_column is kind of an occurence counter for the email address.
# Then we create the Many2many relation between the destination list and the contacts
# while avoiding to insert an existing email address (if the destination is in the source
# for example)
self.ensure_one()
# Put destination is sources lists if not already the case
src_lists |= self
self.env.flush_all()
self.env.cr.execute("""
INSERT INTO mailing_contact_list_rel (contact_id, list_id)
SELECT st.contact_id AS contact_id, %s AS list_id
FROM
(
SELECT
contact.id AS contact_id,
contact.email AS email,
list.id AS list_id,
row_number() OVER (PARTITION BY email ORDER BY email) AS rn
FROM
mailing_contact contact,
mailing_contact_list_rel contact_list_rel,
mailing_list list
WHERE contact.id=contact_list_rel.contact_id
AND COALESCE(contact_list_rel.opt_out,FALSE) = FALSE
AND contact.email_normalized NOT IN (select email from mail_blacklist where active = TRUE)
AND list.id=contact_list_rel.list_id
AND list.id IN %s
AND NOT EXISTS
(
SELECT 1
FROM
mailing_contact contact2,
mailing_contact_list_rel contact_list_rel2
WHERE contact2.email = contact.email
AND contact_list_rel2.contact_id = contact2.id
AND contact_list_rel2.list_id = %s
)
) st
WHERE st.rn = 1;""", (self.id, tuple(src_lists.ids), self.id))
self.env.invalidate_all()
if archive:
(src_lists - self).action_archive()
def close_dialog(self):
return {'type': 'ir.actions.act_window_close'}
# ------------------------------------------------------
# MAILING
# ------------------------------------------------------
def _mailing_get_default_domain(self, mailing):
return [('list_ids', 'in', mailing.contact_list_ids.ids)]
def _mailing_get_opt_out_list(self, mailing):
""" Check subscription on all involved mailing lists. If user is opt_out
on one list but not on another if two users with same email address, one
opted in and the other one opted out, send the mail anyway. """
# TODO DBE Fixme : Optimize the following to get real opt_out and opt_in
subscriptions = self.subscription_ids if self else mailing.contact_list_ids.subscription_ids
opt_out_contacts = subscriptions.filtered(lambda rel: rel.opt_out).mapped('contact_id.email_normalized')
opt_in_contacts = subscriptions.filtered(lambda rel: not rel.opt_out).mapped('contact_id.email_normalized')
opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts)
return opt_out
# ------------------------------------------------------
# UTILITY
# ------------------------------------------------------
def _fetch_contact_statistics(self):
""" Compute number of contacts matching various conditions.
(see '_get_contact_count_select_fields' for details)
Will return a dict under the form:
{
42: { # 42 being the mailing list ID
'contact_count': 52,
'contact_count_email': 35,
'contact_count_opt_out': 5,
'contact_count_blacklisted': 2
},
...
} """
res = []
if self.ids:
self.env.cr.execute(f'''
SELECT
{','.join(self._get_contact_statistics_fields().values())}
FROM
mailing_contact_list_rel r
{self._get_contact_statistics_joins()}
WHERE list_id IN %s
GROUP BY
list_id;
''', (tuple(self.ids), ))
res = self.env.cr.dictfetchall()
contact_counts = {}
for res_item in res:
mailing_list_id = res_item.pop('mailing_list_id')
contact_counts[mailing_list_id] = res_item
for mass_mailing in self:
# adds default 0 values for ids that don't have statistics
if mass_mailing.id not in contact_counts:
contact_counts[mass_mailing.id] = {
field: 0
for field in mass_mailing._get_contact_statistics_fields()
}
return contact_counts
def _get_contact_statistics_fields(self):
""" Returns fields and SQL query select path in a dictionnary.
This is done to be easily overridable in subsequent modules.
- mailing_list_id id of the associated mailing.list
- contact_count: all contacts
- contact_count_email: all valid emails
- contact_count_opt_out: all opted-out contacts
- contact_count_blacklisted: all blacklisted contacts """
return {
'mailing_list_id': 'list_id AS mailing_list_id',
'contact_count': 'COUNT(*) AS contact_count',
'contact_count_email': '''
SUM(CASE WHEN
(c.email_normalized IS NOT NULL
AND COALESCE(r.opt_out,FALSE) = FALSE
AND bl.id IS NULL)
THEN 1 ELSE 0 END) AS contact_count_email''',
'contact_count_opt_out': '''
SUM(CASE WHEN COALESCE(r.opt_out,FALSE) = TRUE
THEN 1 ELSE 0 END) AS contact_count_opt_out''',
'contact_count_blacklisted': '''
SUM(CASE WHEN bl.id IS NOT NULL
THEN 1 ELSE 0 END) AS contact_count_blacklisted'''
}
def _get_contact_statistics_joins(self):
""" Extracted to be easily overridable by sub-modules (such as mass_mailing_sms). """
return """
LEFT JOIN mailing_contact c ON (r.contact_id=c.id)
LEFT JOIN mail_blacklist bl on c.email_normalized = bl.email and bl.active"""

View file

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class MailingTrace(models.Model):
""" MailingTrace models the statistics collected about emails. Those statistics
are stored in a separated model and table to avoid bloating the mail_mail table
with statistics values. This also allows to delete emails send with mass mailing
without loosing the statistics about them.
Note:: State management / Error codes / Failure types summary
* trace_status
'outgoing', 'sent', 'opened', 'replied',
'error', 'bouce', 'cancel'
* failure_type
# generic
'unknown',
# mass_mailing
"mail_email_invalid", "mail_smtp", "mail_email_missing"
# mass mailing mass mode specific codes
"mail_bl", "mail_optout", "mail_dup"
# mass_mailing_sms
'sms_number_missing', 'sms_number_format', 'sms_credit',
'sms_server', 'sms_acc'
# mass_mailing_sms mass mode specific codes
'sms_blacklist', 'sms_duplicate', 'sms_optout',
* cancel:
* mail: set in get_mail_values in composer, if email is blacklisted
(mail) or in opt_out / seen list (mass_mailing) or email_to is void
or incorrectly formatted (mass_mailing) - based on mail cancel state
* sms: set in _prepare_mass_sms_trace_values in composer if sms is
in cancel state; either blacklisted (sms) or in opt_out / seen list
(sms);
* void mail / void sms number -> error (mail_missing, sms_number_missing)
* invalid mail / invalid sms number -> error (RECIPIENT, sms_number_format)
* exception: set in _postprocess_sent_message (_postprocess_iap_sent_sms)
if mail (sms) not sent with failure type, reset if sent;
* sent: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if
mail (sms) sent
* clicked: triggered by add_click
* opened: triggered by add_click + blank gif (mail) + gateway reply (mail)
* replied: triggered by gateway reply (mail)
* bounced: triggered by gateway bounce (mail) or in _prepare_mass_sms_trace_values
if sms_number_format error when sending sms (sms)
"""
_name = 'mailing.trace'
_description = 'Mailing Statistics'
_rec_name = 'id'
_order = 'create_date DESC'
trace_type = fields.Selection([('mail', 'Email')], string='Type', default='mail', required=True)
display_name = fields.Char(compute='_compute_display_name')
# mail data
mail_mail_id = fields.Many2one('mail.mail', string='Mail', index='btree_not_null')
mail_mail_id_int = fields.Integer(
string='Mail ID (tech)',
help='ID of the related mail_mail. This field is an integer field because '
'the related mail_mail can be deleted separately from its statistics. '
'However the ID is needed for several action and controllers.',
index='btree_not_null',
)
email = fields.Char(string="Email", help="Normalized email address")
message_id = fields.Char(string='Message-ID') # email Message-ID (RFC 2392)
medium_id = fields.Many2one(related='mass_mailing_id.medium_id')
source_id = fields.Many2one(related='mass_mailing_id.source_id')
# document
model = fields.Char(string='Document model', required=True)
res_id = fields.Many2oneReference(string='Document ID', model_field='model')
# campaign data
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True, ondelete='cascade')
campaign_id = fields.Many2one(
related='mass_mailing_id.campaign_id',
string='Campaign',
store=True, readonly=True, index='btree_not_null')
# Status
sent_datetime = fields.Datetime('Sent On')
open_datetime = fields.Datetime('Opened On')
reply_datetime = fields.Datetime('Replied On')
trace_status = fields.Selection(selection=[
('outgoing', 'Outgoing'),
('sent', 'Sent'),
('open', 'Opened'),
('reply', 'Replied'),
('bounce', 'Bounced'),
('error', 'Exception'),
('cancel', 'Canceled')], string='Status', default='outgoing')
failure_type = fields.Selection(selection=[
# generic
("unknown", "Unknown error"),
# mail
("mail_email_invalid", "Invalid email address"),
("mail_email_missing", "Missing email address"),
("mail_smtp", "Connection failed (outgoing mail server problem)"),
# mass mode
("mail_bl", "Blacklisted Address"),
("mail_optout", "Opted Out"),
("mail_dup", "Duplicated Email"),
], string='Failure type')
# Link tracking
links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click')
links_click_datetime = fields.Datetime('Clicked On', help='Stores last click datetime in case of multi clicks.')
_sql_constraints = [
# Required on a Many2one reference field is not sufficient as actually
# writing 0 is considered as a valid value, because this is an integer field.
# We therefore need a specific constraint check.
('check_res_id_is_set',
'CHECK(res_id IS NOT NULL AND res_id !=0 )',
'Traces have to be linked to records with a not null res_id.')
]
@api.depends('trace_type', 'mass_mailing_id')
def _compute_display_name(self):
for trace in self:
trace.display_name = '%s: %s (%s)' % (trace.trace_type, trace.mass_mailing_id.name, trace.id)
@api.model_create_multi
def create(self, values_list):
for values in values_list:
if 'mail_mail_id' in values:
values['mail_mail_id_int'] = values['mail_mail_id']
return super(MailingTrace, self).create(values_list)
def action_view_contact(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': self.model,
'target': 'current',
'res_id': self.res_id
}
def set_sent(self, domain=None):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'sent', 'sent_datetime': fields.Datetime.now(), 'failure_type': False})
return traces
def set_opened(self, domain=None):
""" Reply / Open are a bit shared in various processes: reply implies
open, click implies open. Let us avoid status override by skipping traces
that are not already opened or replied. """
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.filtered(lambda t: t.trace_status not in ('open', 'reply')).write({'trace_status': 'open', 'open_datetime': fields.Datetime.now()})
return traces
def set_clicked(self, domain=None):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'links_click_datetime': fields.Datetime.now()})
return traces
def set_replied(self, domain=None):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'reply', 'reply_datetime': fields.Datetime.now()})
return traces
def set_bounced(self, domain=None):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'bounce'})
return traces
def set_failed(self, domain=None, failure_type=False):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'error', 'failure_type': failure_type})
return traces
def set_canceled(self, domain=None):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'cancel'})
return traces

View file

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResCompany(models.Model):
_inherit = "res.company"
def _get_social_media_links(self):
self.ensure_one()
return {
'social_facebook': self.social_facebook,
'social_linkedin': self.social_linkedin,
'social_twitter': self.social_twitter,
'social_instagram': self.social_instagram
}

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
group_mass_mailing_campaign = fields.Boolean(
string="Mailing Campaigns",
implied_group='mass_mailing.group_mass_mailing_campaign',
help="""This is useful if your marketing campaigns are composed of several emails""")
mass_mailing_outgoing_mail_server = fields.Boolean(
string="Dedicated Server",
config_parameter='mass_mailing.outgoing_mail_server',
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.')
mass_mailing_mail_server_id = fields.Many2one(
'ir.mail_server', string='Mail Server',
config_parameter='mass_mailing.mail_server_id')
show_blacklist_buttons = fields.Boolean(
string="Blacklist Option when Unsubscribing",
config_parameter='mass_mailing.show_blacklist_buttons',
help="""Allow the recipient to manage themselves their state in the blacklist via the unsubscription page.""")
mass_mailing_reports = fields.Boolean(
string='24H Stat Mailing Reports',
config_parameter='mass_mailing.mass_mailing_reports',
help='Check how well your mailing is doing a day after it has been sent.')
@api.onchange('mass_mailing_outgoing_mail_server')
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

View file

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Partner(models.Model):
_inherit = 'res.partner'
_mailing_enabled = True

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, _
class Users(models.Model):
_name = 'res.users'
_inherit = ['res.users']
@api.model
def systray_get_activities(self):
""" Update systray name of mailing.mailing from "Mass Mailing"
to "Email Marketing".
"""
activities = super(Users, self).systray_get_activities()
for activity in activities:
if activity.get('model') == 'mailing.mailing':
activity['name'] = _('Email Marketing')
break
return activities

View file

@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class UtmCampaign(models.Model):
_inherit = 'utm.campaign'
mailing_mail_ids = fields.One2many(
'mailing.mailing', 'campaign_id',
domain=[('mailing_type', '=', 'mail')],
string='Mass Mailings',
groups="mass_mailing.group_mass_mailing_user")
mailing_mail_count = fields.Integer('Number of Mass Mailing',
compute="_compute_mailing_mail_count",
groups="mass_mailing.group_mass_mailing_user")
is_mailing_campaign_activated = fields.Boolean(compute="_compute_is_mailing_campaign_activated")
# 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", copy=False)
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), ('mailing_type', '=', 'mail')],
['campaign_id', 'ab_testing_enabled'],
['campaign_id', 'ab_testing_enabled'],
lazy=False,
)
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 = sum(mapped_data.get(campaign._origin.id or campaign.id, []))
campaign.ab_testing_mailings_count = sum(ab_testing_mapped_data.get(campaign._origin.id or 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 """
default_vals = {
'received_ratio': 0,
'opened_ratio': 0,
'replied_ratio': 0,
'bounced_ratio': 0
}
if not self.ids:
self.update(default_vals)
return
self.env.cr.execute("""
SELECT
c.id as campaign_id,
COUNT(s.id) AS expected,
COUNT(s.sent_datetime) AS sent,
COUNT(s.trace_status) FILTER (WHERE s.trace_status in ('sent', 'open', 'reply')) AS delivered,
COUNT(s.trace_status) FILTER (WHERE s.trace_status in ('open', 'reply')) AS open,
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'reply') AS reply,
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'bounce') AS bounce,
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'cancel') AS cancel
FROM
mailing_trace s
RIGHT JOIN
utm_campaign c
ON (c.id = s.campaign_id)
WHERE
c.id IN %s
GROUP BY
c.id
""", (tuple(self.ids), ))
all_stats = self.env.cr.dictfetchall()
stats_per_campaign = {
stats['campaign_id']: stats
for stats in all_stats
}
for campaign in self:
stats = stats_per_campaign.get(campaign.id)
if not stats:
vals = default_vals
else:
total = (stats['expected'] - stats['cancel']) or 1
delivered = stats['sent'] - stats['bounce']
vals = {
'received_ratio': 100.0 * delivered / total,
'opened_ratio': 100.0 * stats['open'] / total,
'replied_ratio': 100.0 * stats['reply'] / total,
'bounced_ratio': 100.0 * stats['bounce'] / total
}
campaign.update(vals)
def _compute_is_mailing_campaign_activated(self):
self.is_mailing_campaign_activated = self.env.user.has_group('mass_mailing.group_mass_mailing_campaign')
def _get_mailing_recipients(self, model=None):
"""Return the recipients of a mailing campaign. This is based on the statistics
build for each mailing. """
res = dict.fromkeys(self.ids, {})
for campaign in self:
domain = [('campaign_id', '=', campaign.id)]
if 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

View file

@ -0,0 +1,23 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models
from odoo.exceptions import UserError
class UtmMedium(models.Model):
_inherit = 'utm.medium'
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_mailings(self):
""" Already handled by ondelete='restrict', but let's show a nice error message """
linked_mailings = self.env['mailing.mailing'].sudo().search([
('medium_id', 'in', self.ids)
])
if linked_mailings:
raise UserError(_(
"You cannot delete these UTM Mediums as they are linked to the following mailings in "
"Mass Mailing:\n%(mailing_names)s",
mailing_names=', '.join(['"%s"' % subject for subject in linked_mailings.mapped('subject')])))

View file

@ -0,0 +1,23 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models
from odoo.exceptions import UserError
class UtmSource(models.Model):
_inherit = 'utm.source'
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_mailings(self):
""" Already handled by ondelete='restrict', but let's show a nice error message """
linked_mailings = self.env['mailing.mailing'].sudo().search([
('source_id', 'in', self.ids)
])
if linked_mailings:
raise UserError(_(
"You cannot delete these UTM Sources as they are linked to the following mailings in "
"Mass Mailing:\n%(mailing_names)s",
mailing_names=', '.join(['"%s"' % subject for subject in linked_mailings.mapped('subject')])))