19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:39 +01:00
parent 5df8c07b59
commit daa394e8b0
2114 changed files with 564841 additions and 299642 deletions

View file

@ -5,9 +5,11 @@ from . import ir_http
from . import ir_mail_server
from . import ir_model
from . import link_tracker
from . import mailing_contact_subscription
from . import mail_blacklist
from . import mailing_subscription # keep before due to decorated m2m
from . import mailing_contact
from . import mailing_list
from . import mailing_subscription_optout
from . import mailing_trace
from . import mailing
from . import mailing_filter

View file

@ -5,9 +5,8 @@ from odoo import _, fields, models
from odoo.tools.misc import format_date
class IrMailServer(models.Model):
_name = 'ir.mail_server'
_inherit = ['ir.mail_server']
class IrMail_Server(models.Model):
_inherit = 'ir.mail_server'
active_mailing_ids = fields.One2many(
comodel_name='mailing.mailing',
@ -24,7 +23,7 @@ class IrMailServer(models.Model):
details = _('(scheduled for %s)', format_date(self.env, mailing_id.schedule_date))
return f'{base} {details}'
usages_super = super(IrMailServer, self)._active_usages_compute()
usages_super = super()._active_usages_compute()
default_mail_server_id = self.env['mailing.mailing']._get_default_mail_server_id()
for record in self:
usages = []

View file

@ -18,17 +18,13 @@ class IrModel(models.Model):
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 '!='."))
if operator not in ('in', 'not in'):
return NotImplemented
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
valid_models = self.search([]).filtered(
lambda model: model.model in self.env
and not model.is_transient()
and getattr(self.env[model.model], '_mailing_enabled', False)
)
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)]
return [('id', operator, valid_models.ids)]

View file

@ -13,8 +13,8 @@ class LinkTracker(models.Model):
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')
mailing_trace_id = fields.Many2one('mailing.trace', string='Mail Statistics', index='btree_not_null')
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing', index='btree_not_null')
def _prepare_click_values_from_route(self, **route_values):
click_values = super(LinkTrackerClick, self)._prepare_click_values_from_route(**route_values)

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailBlacklist(models.Model):
""" Model of blacklisted email addresses to stop sending emails."""
_inherit = 'mail.blacklist'
opt_out_reason_id = fields.Many2one(
'mailing.subscription.optout', string='Opt-out Reason',
ondelete='restrict',
tracking=10)
def _track_subtype(self, init_values):
self.ensure_one()
if 'opt_out_reason_id' in init_values and self.opt_out_reason_id:
return self.env.ref('mail.mt_comment')
return super()._track_subtype(init_values)

View file

@ -4,90 +4,119 @@
import re
import werkzeug.urls
from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, tools
class MailMail(models.Model):
"""Add the mass mailing campaign data to mail"""
_inherit = ['mail.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))
token = self._generate_mail_recipient_token(self.id)
return tools.urls.urljoin(
self.get_base_url(),
f'mail/track/{self.id}/{token}/blank.gif'
)
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
@api.model
def _generate_mail_recipient_token(self, mail_id):
return tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', mail_id)
def _prepare_outgoing_body(self):
""" Override to add the tracking URL to the body and to add trace ID in
shortened urls """
self.ensure_one()
body = super(MailMail, self)._send_prepare_body()
# super() already cleans pseudo-void content from editor
body = super()._prepare_outgoing_body()
if self.mailing_id and body and self.mailing_trace_ids:
for match in set(re.findall(tools.URL_REGEX, self.body_html)):
if body and self.mailing_id and self.mailing_trace_ids:
Wrapper = body.__class__
for match in set(re.findall(tools.mail.URL_REGEX, body)):
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)
new_href = href.replace(url, f"{url}/m/{self.mailing_trace_ids[0].id}")
body = body.replace(Wrapper(href), Wrapper(new_href))
# generate tracking URL
tracking_url = self._get_tracking_url()
body = tools.append_content_to_html(
body = tools.mail.append_content_to_html(
body,
'<img src="%s"/>' % tracking_url,
f'<img src="{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
def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None):
""" Update mailing specific links to replace generic unsubscribe and
view links by email-specific links. Also add headers to allow
unsubscribe from email managers. """
email_list = super()._prepare_outgoing_list(mail_server=mail_server, doc_to_followers=doc_to_followers)
if not self.res_id or not self.mailing_id:
return email_list
base_url = self.mailing_id.get_base_url()
for email_values in email_list:
if not email_values['email_to']:
continue
# prepare links with normalize email
email_normalized = tools.email_normalize(email_values['email_to'][0], strict=False)
email_to = email_normalized or email_values['email_to'][0]
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(
if not tools.is_html_empty(email_values['body']):
# replace generic link by recipient-specific one, except if we know
# by advance it won't work (i.e. testing mailing scenario)
if f'{base_url}/unsubscribe_from_list' in email_values['body'] and not self.env.context.get('mailing_test_mail'):
email_values['body'] = email_values['body'].replace(
f'{base_url}/unsubscribe_from_list',
unsubscribe_url,
)
if f'{base_url}/view' in res.get('body'):
res['body'] = res['body'].replace(
if f'{base_url}/view' in email_values['body']:
email_values['body'] = email_values['body'].replace(
f'{base_url}/view',
view_url,
)
# add headers
res.setdefault("headers", {}).update({
email_values['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
return email_list
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)
def _postprocess_sent_message(self, success_pids, success_emails, failure_reason=False, failure_type=None):
if failure_type: # we consider that a recipient error is a failure with mass mailing and show them as failed
self.filtered('mailing_id').mailing_trace_ids.set_failed(failure_type=failure_type)
else:
self.filtered('mailing_id').mailing_trace_ids.set_sent()
return super()._postprocess_sent_message(success_pids, success_emails, failure_reason=failure_reason, failure_type=failure_type)
@api.autovacuum
def _gc_canceled_mail_mail(self):
"""Garbage collects old canceled mail.mail records as we consider
nobody is going to look at them anymore, becoming noise."""
# The 10000 limit is arbitrary, chosen a big limit so that the cleaning can be shorter and not too big so that we don't block the server
months_limit = self.env['ir.config_parameter'].sudo().get_param("mass_mailing.cancelled_mails_months_limit", 6)
if months_limit <= 0:
return
history_deadline = datetime.utcnow() - relativedelta(months=months_limit) # 6 months history will be kept
canceled_mails = self.with_context(active_test=False).search([('state', '=', 'cancel'), ('write_date', '<=', history_deadline)], order="id asc", limit=10000)
canceled_mails.with_context(prefetch_fields=False).mail_message_id.unlink()

View file

@ -8,9 +8,9 @@ class MailRenderMixin(models.AbstractModel):
_inherit = "mail.render.mixin"
@api.model
def _render_template_postprocess(self, rendered):
def _render_template_postprocess(self, model, rendered):
# super will transform relative url to absolute
rendered = super(MailRenderMixin, self)._render_template_postprocess(rendered)
rendered = super()._render_template_postprocess(model, rendered)
# apply shortener after
if self.env.context.get('post_convert_links'):
@ -18,6 +18,6 @@ class MailRenderMixin(models.AbstractModel):
rendered[res_id] = self._shorten_links(
html,
self.env.context['post_convert_links'],
blacklist=['/unsubscribe_from_list', '/view']
blacklist=['/unsubscribe_from_list', '/view', '/cards']
)
return rendered

View file

@ -2,8 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from markupsafe import Markup
from odoo import api, models, fields, tools
from odoo import api, models, fields, tools, _
BLACKLIST_MAX_BOUNCED_LIMIT = 5
@ -14,66 +15,72 @@ class MailThread(models.AbstractModel):
@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. """
# 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)
msg_references = tools.mail.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):
def message_mail_with_source(self, source_ref, **kwargs):
# avoid having message send through `message_post*` methods being implicitly considered as
# mass-mailing
no_massmail = self.with_context(
return super(MailThread, 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)
)).message_mail_with_source(source_ref, **kwargs)
def message_post_with_source(self, source_ref, **kwargs):
# avoid having message send through `message_post*` methods being implicitly considered as
# mass-mailing
return super(MailThread, self.with_context(
default_mass_mailing_name=False,
default_mass_mailing_id=False,
)).message_post_with_source(source_ref, **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."""
# 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_msg_ids = message_dict['bounced_msg_ids']
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_msg_ids:
self.env['mailing.trace'].set_bounced(
domain=[('message_id', 'in', bounced_msg_ids)],
bounce_message=tools.html2plaintext(message_dict.get('body') or ''))
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.')
self.env['mail.blacklist'].sudo()._add(
bounced_email,
message=Markup('<p>%s</p>') % _('This email has been automatically added in blocklist 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)
msg_references = tools.mail.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:

View file

@ -3,81 +3,43 @@
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.fields import Domain
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):
class MailingContact(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']
_inherit = ['mail.thread.blacklist', 'properties.base.definition.mixin']
_description = 'Mailing Contact'
_order = 'email'
_order = 'name ASC, id DESC'
_mailing_enabled = True
def default_get(self, fields_list):
@api.model
def default_get(self, fields):
""" 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
key. We should use it to create subscription_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'):
res = super().default_get(fields)
if 'subscription_ids' in fields and not res.get('subscription_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'] = [
res['subscription_ids'] = [
(0, 0, {'list_id': list_id}) for list_id in list_ids]
return res
name = fields.Char()
name = fields.Char('Name', compute='_compute_name', readonly=False, store=True, tracking=True)
first_name = fields.Char('First Name')
last_name = fields.Char('Last Name')
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',
'mailing.list', 'mailing_subscription',
'contact_id', 'list_id', string='Mailing Lists')
subscription_list_ids = fields.One2many(
'mailing.contact.subscription', 'contact_id', string='Subscription Information')
subscription_ids = fields.One2many(
'mailing.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(
@ -86,45 +48,53 @@ class MassMailingContact(models.Model):
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 fields_get(self, allfields=None, attributes=None):
""" Hide first and last name field if the split name feature is not enabled. """
res = super().fields_get(allfields, attributes)
if not self._is_name_split_activated():
if 'first_name' in res:
res['first_name']['searchable'] = False
if 'last_name' in res:
res['last_name']['searchable'] = False
return res
@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 operator != 'in':
return NotImplemented
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
if 'default_list_ids' in self.env.context and isinstance(self.env.context['default_list_ids'], (list, tuple)) and len(self.env.context['default_list_ids']) == 1:
[active_list_id] = self.env.context['default_list_ids']
subscriptions = self.env['mailing.subscription']._search([
('list_id', '=', active_list_id),
('opt_out', '=', True),
])
return [('id', 'in', subscriptions.subselect('contact_id'))]
return Domain.FALSE
@api.depends('subscription_list_ids')
@api.depends('first_name', 'last_name')
def _compute_name(self):
for record in self:
if record.first_name or record.last_name:
record.name = ' '.join(name_part for name_part in (record.first_name, record.last_name) if name_part)
@api.depends('subscription_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']
if 'default_list_ids' in self.env.context and isinstance(self.env.context['default_list_ids'], (list, tuple)) and len(self.env.context['default_list_ids']) == 1:
[active_list_id] = self.env.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)
active_subscription_list = record.subscription_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
fields) default key with subscription_ids given by user when creating
contacts.
Those two values have the same purpose, adding a list to to the contact
@ -134,29 +104,38 @@ class MassMailingContact(models.Model):
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 = self.env.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 vals.get('list_ids') and vals.get('subscription_ids'):
raise UserError(_('You should give either list_ids, either subscription_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 []
subscription_ids = vals.get('subscription_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
vals['subscription_ids'] = subscription_ids
return super(MassMailingContact, self.with_context(default_list_ids=False)).create(vals_list)
records = super(MailingContact, self.with_context(default_list_ids=False)).create(vals_list)
# We need to invalidate list_ids or subscription_ids because list_ids is a many2many
# using a real model as table ('mailing.subscription') and the ORM doesn't automatically
# update/invalidate the `list_ids`/`subscription_ids` cache correctly.
for record in records:
if record.list_ids:
record.invalidate_recordset(['subscription_ids'])
elif record.subscription_ids:
record.invalidate_recordset(['list_ids'])
return records
@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
@ -167,24 +146,26 @@ class MassMailingContact(models.Model):
@api.model
def name_create(self, name):
name, email = self.get_name_email(name)
name, email = tools.parse_contact_from_email(name)
contact = self.create({'name': name, 'email': email})
return contact.name_get()[0]
return contact.id, contact.display_name
@api.model
def add_to_list(self, name, list_id):
name, email = self.get_name_email(name)
name, email = tools.parse_contact_from_email(name)
contact = self.create({'name': name, 'email': email, 'list_ids': [(4, list_id)]})
return contact.name_get()[0]
return contact.id, contact.display_name
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_import(self):
action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.mailing_contact_import_action")
context = self.env.context.copy()
action['context'] = context
if (not context.get('default_mailing_list_ids') and context.get('from_mailing_list_ids')):
action['context'].update({
'default_mailing_list_ids': context.get('from_mailing_list_ids'),
})
return action
def action_add_to_mailing_list(self):
ctx = dict(self.env.context, default_contact_ids=self.ids)
@ -201,3 +182,9 @@ class MassMailingContact(models.Model):
'label': _('Import Template for Mailing List Contacts'),
'template': '/mass_mailing/static/xls/mailing_contact.xls'
}]
@api.model
def _is_name_split_activated(self):
""" Return whether the contact names are populated as first and last name or as a single field (name). """
view = self.env.ref("mass_mailing.mailing_contact_view_tree_split_name", raise_if_not_found=False)
return view and view.sudo().active

View file

@ -1,47 +0,0 @@
# -*- 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

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, Command, fields, models
from markupsafe import Markup
from odoo import _, api, Command, fields, models, tools
from odoo.exceptions import UserError
class MassMailingList(models.Model):
class MailingList(models.Model):
"""Model of a contact list. """
_name = 'mailing.list'
_order = 'name'
@ -25,26 +27,27 @@ class MassMailingList(models.Model):
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',
'mailing.contact', 'mailing_subscription', 'list_id', 'contact_id',
string='Mailing Lists', copy=False)
mailing_count = fields.Integer(compute="_compute_mailing_list_count", string="Number of Mailing")
mailing_count = fields.Integer(compute="_compute_mailing_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',
'mailing.subscription', 'list_id',
string='Subscription Information',
copy=True, depends=['contact_ids'])
is_public = fields.Boolean(
string='Show In Preferences', default=True,
string='Show In Preferences', default=False,
help='The mailing list can be accessible by recipients in the subscription '
'management page to allows them to update their preferences.')
'management page to allow them to update their preferences.')
# ------------------------------------------------------
# COMPUTE / ONCHANGE
# ------------------------------------------------------
def _compute_mailing_list_count(self):
@api.depends('mailing_ids')
def _compute_mailing_count(self):
data = {}
if self.ids:
self.env.cr.execute('''
@ -56,12 +59,15 @@ class MassMailingList(models.Model):
for mailing_list in self:
mailing_list.mailing_count = data.get(mailing_list._origin.id, 0)
@api.depends('contact_ids')
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. """
# flush, notably to have email_normalized computed on contact model
self.env.flush_all()
# 1. Fetch contact data and associated counts (total / blacklist / opt-out)
contact_statistics_per_mailing = self._fetch_contact_statistics()
@ -72,13 +78,13 @@ class MassMailingList(models.Model):
bounce_per_mailing = {}
if self.ids:
sql = '''
SELECT mclr.list_id, COUNT(DISTINCT mc.id)
SELECT list_sub.list_id, COUNT(DISTINCT mc.id)
FROM mailing_contact mc
LEFT OUTER JOIN mailing_contact_list_rel mclr
ON mc.id = mclr.contact_id
LEFT OUTER JOIN mailing_subscription list_sub
ON mc.id = list_sub.contact_id
WHERE mc.message_bounce > 0
AND mclr.list_id in %s
GROUP BY mclr.list_id
AND list_sub.list_id in %s
GROUP BY list_sub.list_id
'''
self.env.cr.execute(sql, (tuple(self.ids),))
bounce_per_mailing = dict(self.env.cr.fetchall())
@ -114,17 +120,16 @@ class MassMailingList(models.Model):
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)
return super().write(vals)
def name_get(self):
return [(list.id, "%s (%s)" % (list.name, list.contact_count)) for list in self]
@api.depends('contact_count')
def _compute_display_name(self):
for mailing_list in self:
mailing_list.display_name = f"{mailing_list.name} ({mailing_list.contact_count})"
def copy(self, default=None):
self.ensure_one()
default = dict(default or {},
name=_('%s (copy)', self.name),)
return super(MassMailingList, self).copy(default)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", mailing_list.name)) for mailing_list, vals in zip(self, vals_list)]
# ------------------------------------------------------
# ACTIONS
@ -136,7 +141,7 @@ class MassMailingList(models.Model):
action['context'] = {
**self.env.context,
'default_mailing_list_ids': self.ids,
'default_subscription_list_ids': [
'default_subscription_ids': [
Command.create({'list_id': mailing_list.id})
for mailing_list in self
],
@ -145,17 +150,17 @@ class MassMailingList(models.Model):
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,
'default_mailing_type': 'mail',
'default_model_id': self.env['ir.model']._get_id('mailing.list'),
},
'target': 'current',
'view_type': 'form',
'views': [(view.id, 'form')],
})
return action
@ -201,7 +206,7 @@ class MassMailingList(models.Model):
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
# Explanation 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
@ -215,7 +220,7 @@ class MassMailingList(models.Model):
# 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.
# The row_column is kind of an occurrence 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)
@ -224,7 +229,7 @@ class MassMailingList(models.Model):
src_lists |= self
self.env.flush_all()
self.env.cr.execute("""
INSERT INTO mailing_contact_list_rel (contact_id, list_id)
INSERT INTO mailing_subscription (contact_id, list_id)
SELECT st.contact_id AS contact_id, %s AS list_id
FROM
(
@ -235,7 +240,7 @@ class MassMailingList(models.Model):
row_number() OVER (PARTITION BY email ORDER BY email) AS rn
FROM
mailing_contact contact,
mailing_contact_list_rel contact_list_rel,
mailing_subscription contact_list_rel,
mailing_list list
WHERE contact.id=contact_list_rel.contact_id
AND COALESCE(contact_list_rel.opt_out,FALSE) = FALSE
@ -247,7 +252,7 @@ class MassMailingList(models.Model):
SELECT 1
FROM
mailing_contact contact2,
mailing_contact_list_rel contact_list_rel2
mailing_subscription contact_list_rel2
WHERE contact2.email = contact.email
AND contact_list_rel2.contact_id = contact2.id
AND contact_list_rel2.list_id = %s
@ -258,8 +263,88 @@ class MassMailingList(models.Model):
if archive:
(src_lists - self).action_archive()
def close_dialog(self):
return {'type': 'ir.actions.act_window_close'}
# ------------------------------------------------------
# SUBSCRIPTION MANAGEMENT
# ------------------------------------------------------
def _update_subscription_from_email(self, email, opt_out=True, force_message=None):
""" When opting-out: we have to switch opted-in subscriptions. We don't
need to create subscription for other lists as opt-out = not being a
member.
When opting-in: we have to switch opted-out subscriptions and create
subscription for other mailing lists id they are public. Indeed a
contact is opted-in when being subscribed in a mailing list.
:param str email: email address that should opt-in or opt-out from
mailing lists;
:param boolean opt_out: if True, opt-out from lists given by self if
'email' is member of it. If False, opt-in in lists givben by self
and create membership if not already member;
:param str force_message: if given, post a note using that body on
contact instead of generated update message. Give False to entirely
skip the note step;
"""
email_normalized = tools.email_normalize(email)
if not self or not email_normalized:
return
contacts = self.env['mailing.contact'].with_context(active_test=False).search(
[('email_normalized', '=', email_normalized)]
)
if not contacts:
return
# switch opted-in subscriptions
if opt_out:
current_opt_in = contacts.subscription_ids.filtered(
lambda sub: not sub.opt_out and sub.list_id in self
)
if current_opt_in:
current_opt_in.write({'opt_out': True})
# switch opted-out subscription and create missing subscriptions
else:
subscriptions = contacts.subscription_ids.filtered(lambda sub: sub.list_id in self)
current_opt_out = subscriptions.filtered('opt_out')
if current_opt_out:
current_opt_out.write({'opt_out': False})
# create a subscription (for a single contact) for missing lists
missing_lists = self - subscriptions.list_id
if missing_lists:
self.env['mailing.subscription'].create([
{'contact_id': contacts[0].id,
'list_id': mailing_list.id}
for mailing_list in missing_lists
])
for contact in contacts:
# do not log if no opt-out / opt-in was actually done
if opt_out:
updated = current_opt_in.filtered(lambda sub: sub.contact_id == contact).list_id
else:
updated = current_opt_out.filtered(lambda sub: sub.contact_id == contact).list_id + missing_lists
if not updated:
continue
if force_message is False:
continue
if force_message:
body = force_message
elif opt_out:
body = Markup('<p>%s</p><ul>%s</ul>') % (
_('%(contact_name)s unsubscribed from the following mailing list(s)', contact_name=contact.display_name),
Markup().join(Markup('<li>%s</li>') % name for name in updated.mapped('name')),
)
else:
body = Markup('<p>%s</p><ul>%s</ul>') % (
_('%(contact_name)s subscribed to the following mailing list(s)', contact_name=contact.display_name),
Markup().join(Markup('<li>%s</li>') % name for name in updated.mapped('name')),
)
contact.with_context(mail_post_autofollow_author_skip=True).message_post(
body=body,
subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
)
# ------------------------------------------------------
# MAILING
@ -304,7 +389,7 @@ class MassMailingList(models.Model):
SELECT
{','.join(self._get_contact_statistics_fields().values())}
FROM
mailing_contact_list_rel r
mailing_subscription r
{self._get_contact_statistics_joins()}
WHERE list_id IN %s
GROUP BY

View file

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class MailingSubscription(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.subscription'
_description = 'Mailing List Subscription'
_table = 'mailing_subscription'
_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, index=True)
opt_out = fields.Boolean(
string='Opt Out',
default=False,
help='The contact has chosen not to receive mails anymore from this list')
opt_out_reason_id = fields.Many2one(
'mailing.subscription.optout', string='Reason',
ondelete='restrict')
opt_out_datetime = fields.Datetime(
string='Unsubscription Date',
compute='_compute_opt_out_datetime', readonly=False, store=True)
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)
_unique_contact_list = models.Constraint(
'unique (contact_id, list_id)',
'A mailing contact cannot subscribe to the same mailing list multiple times.',
)
@api.depends('opt_out')
def _compute_opt_out_datetime(self):
self.filtered(lambda sub: not sub.opt_out).opt_out_datetime = False
for subscription in self.filtered('opt_out'):
subscription.opt_out_datetime = self.env.cr.now()
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('opt_out_datetime') or vals.get('opt_out_reason_id'):
vals['opt_out'] = True
return super().create(vals_list)
def write(self, vals):
if vals.get('opt_out_datetime') or vals.get('opt_out_reason_id'):
vals['opt_out'] = True
return super().write(vals)
def open_mailing_contact(self):
action = {
'name': _('Mailing Contacts'),
'type': 'ir.actions.act_window',
'view_mode': 'list,form',
'domain': [('id', 'in', self.contact_id.ids)],
'res_model': 'mailing.contact',
}
if len(self) == 1:
action.update({
'name': _('Mailing Contact'),
'view_mode': 'form',
'res_id': self.contact_id.id,
})
return action

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailingSubscriptionOptout(models.Model):
""" Reason for opting out of mailing lists or for blacklisting. """
_name = 'mailing.subscription.optout'
_description = 'Mailing Subscription Reason'
_order = 'sequence ASC, create_date DESC, id DESC'
name = fields.Char(string='Reason', translate=True)
sequence = fields.Integer(string='Sequence', default=10)
is_feedback = fields.Boolean(string='Ask For Feedback')

View file

@ -13,22 +13,23 @@ class MailingTrace(models.Model):
Note:: State management / Error codes / Failure types summary
* trace_status
'outgoing', 'sent', 'opened', 'replied',
'error', 'bouce', 'cancel'
'outgoing', 'process', 'pending', 'sent', 'opened', 'replied',
'error', 'bounce', 'cancel'
* failure_type
# generic
'unknown',
# mass_mailing
"mail_email_invalid", "mail_smtp", "mail_email_missing"
"mail_email_invalid", "mail_smtp", "mail_email_missing",
"mail_from_invalid", "mail_from_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'
'sms_number_missing', 'sms_number_format', 'sms_credit', 'sms_server',
'sms_acc', 'sms_country_not_supported', 'sms_registration_needed',
# 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: set in _prepare_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
@ -38,8 +39,12 @@ class MailingTrace(models.Model):
* 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
* process: (used in sms): set in SmsTracker._update_sms_traces when held back
(at IAP) before actual sending to the sms_service.
* pending: (used in sms): default value for sent sms.
* sent: set in
* _postprocess_sent_message if mail
* SmsTracker._update_sms_traces if sms, when delivery report is received.
* clicked: triggered by add_click
* opened: triggered by add_click + blank gif (mail) + gateway reply (mail)
* replied: triggered by gateway reply (mail)
@ -52,7 +57,7 @@ class MailingTrace(models.Model):
_order = 'create_date DESC'
trace_type = fields.Selection([('mail', 'Email')], string='Type', default='mail', required=True)
display_name = fields.Char(compute='_compute_display_name')
is_test_trace = fields.Boolean('Generated for testing')
# mail data
mail_mail_id = fields.Many2one('mail.mail', string='Mail', index='btree_not_null')
mail_mail_id_int = fields.Integer(
@ -81,48 +86,51 @@ class MailingTrace(models.Model):
reply_datetime = fields.Datetime('Replied On')
trace_status = fields.Selection(selection=[
('outgoing', 'Outgoing'),
('sent', 'Sent'),
('process', 'Processing'),
('pending', 'Sent'),
('sent', 'Delivered'),
('open', 'Opened'),
('reply', 'Replied'),
('bounce', 'Bounced'),
('error', 'Exception'),
('cancel', 'Canceled')], string='Status', default='outgoing')
('cancel', 'Cancelled')], string='Status', default='outgoing')
failure_type = fields.Selection(selection=[
# generic
("unknown", "Unknown error"),
# mail
("mail_bounce", "Bounce"),
("mail_spam", "Detected As Spam"),
("mail_email_invalid", "Invalid email address"),
("mail_email_missing", "Missing email address"),
("mail_from_invalid", "Invalid from address"),
("mail_from_missing", "Missing from address"),
("mail_smtp", "Connection failed (outgoing mail server problem)"),
# mass mode
("mail_bl", "Blacklisted Address"),
("mail_optout", "Opted Out"),
("mail_dup", "Duplicated Email"),
("mail_optout", "Opted Out"),
], string='Failure type')
failure_reason = fields.Text('Failure reason', copy=False, readonly=True)
# 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.')
]
_check_res_id_is_set = models.Constraint(
'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)
trace.display_name = f'{trace.trace_type}: {trace.mass_mailing_id.name} ({trace.id})'
@api.model_create_multi
def create(self, values_list):
for values in values_list:
def create(self, vals_list):
for values in vals_list:
if 'mail_mail_id' in values:
values['mail_mail_id_int'] = values['mail_mail_id']
return super(MailingTrace, self).create(values_list)
return super().create(vals_list)
def action_view_contact(self):
self.ensure_one()
@ -157,9 +165,13 @@ class MailingTrace(models.Model):
traces.write({'trace_status': 'reply', 'reply_datetime': fields.Datetime.now()})
return traces
def set_bounced(self, domain=None):
def set_bounced(self, domain=None, bounce_message=False):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'bounce'})
traces.write({
'failure_reason': bounce_message,
'failure_type': 'mail_bounce',
'trace_status': 'bounce',
})
return traces
def set_failed(self, domain=None, failure_type=False):

View file

@ -13,5 +13,6 @@ class ResCompany(models.Model):
'social_facebook': self.social_facebook,
'social_linkedin': self.social_linkedin,
'social_twitter': self.social_twitter,
'social_instagram': self.social_instagram
'social_instagram': self.social_instagram,
'social_tiktok': self.social_tiktok,
}

View file

@ -26,14 +26,30 @@ class ResConfigSettings(models.TransientModel):
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.')
mass_mailing_split_contact_name = fields.Boolean(
string='Split First and Last Name',
help='Separate Mailing Contact Names into two fields')
@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
@api.model
def get_values(self):
res = super().get_values()
res.update(
mass_mailing_split_contact_name=self.env['mailing.contact']._is_name_split_activated(),
)
return res
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
if self.env['mailing.contact']._is_name_split_activated() != self.mass_mailing_split_contact_name:
self.env.ref(
"mass_mailing.mailing_contact_view_tree_split_name").active = self.mass_mailing_split_contact_name
self.env.ref(
"mass_mailing.mailing_contact_view_form_split_name").active = self.mass_mailing_split_contact_name

View file

@ -4,6 +4,6 @@
from odoo import models
class Partner(models.Model):
class ResPartner(models.Model):
_inherit = 'res.partner'
_mailing_enabled = True

View file

@ -4,16 +4,15 @@
from odoo import api, models, _
class Users(models.Model):
_name = 'res.users'
_inherit = ['res.users']
class ResUsers(models.Model):
_inherit = 'res.users'
@api.model
def systray_get_activities(self):
def _get_activity_groups(self):
""" Update systray name of mailing.mailing from "Mass Mailing"
to "Email Marketing".
"""
activities = super(Users, self).systray_get_activities()
activities = super()._get_activity_groups()
for activity in activities:
if activity.get('model') == 'mailing.mailing':
activity['name'] = _('Email Marketing')

View file

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools.float_utils import float_round
class UtmCampaign(models.Model):
@ -22,11 +24,12 @@ class UtmCampaign(models.Model):
# 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_completed = fields.Boolean("A/B Testing Campaign Finished", compute="_compute_ab_testing_completed",
copy=False, readonly=True, store=True)
ab_testing_winner_mailing_id = fields.Many2one("mailing.mailing", "A/B Campaign Winner Mailing", 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'),
@ -35,45 +38,32 @@ class UtmCampaign(models.Model):
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')
received_ratio = fields.Float(compute="_compute_statistics", string='Received Ratio')
opened_ratio = fields.Float(compute="_compute_statistics", string='Opened Ratio')
replied_ratio = fields.Float(compute="_compute_statistics", string='Replied Ratio')
bounced_ratio = fields.Float(compute="_compute_statistics", string='Bounced Ratio')
@api.depends('mailing_mail_ids')
def _compute_ab_testing_total_pc(self):
@api.depends('ab_testing_winner_mailing_id')
def _compute_ab_testing_completed(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')
])
campaign.ab_testing_completed = bool(self.ab_testing_winner_mailing_id)
@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()
mailing_data = self.env['mailing.mailing']._read_group(
[('campaign_id', 'in', self.ids), ('mailing_type', '=', 'mail')],
['campaign_id', 'ab_testing_enabled'],
['__count'],
)
ab_testing_mapped_data = defaultdict(list)
mapped_data = defaultdict(list)
for campaign, ab_testing_enabled, count in mailing_data:
if ab_testing_enabled:
ab_testing_mapped_data[campaign.id].append(count)
mapped_data[campaign.id].append(count)
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%"))
campaign.mailing_mail_count = sum(mapped_data[campaign._origin.id or campaign.id])
campaign.ab_testing_mailings_count = sum(ab_testing_mapped_data[campaign._origin.id or campaign.id])
def _compute_statistics(self):
""" Compute statistics of the mass mailing campaign """
@ -121,10 +111,10 @@ class UtmCampaign(models.Model):
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
'received_ratio': float_round(100.0 * delivered / total, precision_digits=2),
'opened_ratio': float_round(100.0 * stats['open'] / total, precision_digits=2),
'replied_ratio': float_round(100.0 * stats['reply'] / total, precision_digits=2),
'bounced_ratio': float_round(100.0 * stats['bounce'] / total, precision_digits=2)
}
campaign.update(vals)