mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-21 08:22:05 +02:00
19.0 vanilla
This commit is contained in:
parent
5df8c07b59
commit
daa394e8b0
2114 changed files with 564841 additions and 299642 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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')
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@
|
|||
from odoo import models
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
_mailing_enabled = True
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue