mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-20 22:02:04 +02:00
Initial commit: Mail packages
This commit is contained in:
commit
4e53507711
1948 changed files with 751201 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
1477
odoo-bringout-oca-ocb-mass_mailing/mass_mailing/models/mailing.py
Normal file
1477
odoo-bringout-oca-ocb-mass_mailing/mass_mailing/models/mailing.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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'
|
||||
}]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.")
|
||||
)
|
||||
|
|
@ -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"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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')])))
|
||||
|
|
@ -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')])))
|
||||
Loading…
Add table
Add a link
Reference in a new issue