[REF] mass_mailing: improve traces state management
PURPOSE
Improve modeling and performances of mass mailing by manually updating trace
status instead of using complex computed fields and lessening fields usage.
Remove some fields and keep only relevant metrics to simplify and clean trace
model.
SPECIFICATIONS
In this commit we clean the way trace state is managed. Currently it is a
computed field based on several datetime fields. However this generates a
lot of noise in the table as well as unnecessary computation
* there are several columns (one for each state) storing datetime at
which status was reached. Generally only 2 or 3 contain relevant
information;
* state could be set in code directly to avoid a computed field based on
many triggers;
* recomputing it each time a date changes is not necessarily necessary;
* state value can always be updated manually as this is main done through
some automated server update (mailgateway, link clicks, ...);
As trace states and its triggers should not be updated manually it is better
to synchronize it in code flow. When there is an exception or update done
through sending or gateway status is updated as well accordingly. Various
datetime fields are also updated at the same time. In order to align with
notification model mail and sms trace status are updated to a classic field.
Only last status update is now kept as there is no need to store the entire
history of status change.
We keep only a datetime for relevant metrics: open, reply and click. Other
datetime bring no real value. Knowing when a trace was in error or bounced
is not necessary. Indeed exception generally indicates a server issue (at
sending), cancel indicates a data issue (at sending) and bounce depends on
customer email server.
Status update is removed as using write_date is sufficient. Once created
traces are updated only when an external event occurs (opened, replied, ...).
It allows to simplify trace model.
We also rename ignored field into canceled to match naming use through mail
and sms.
QUERY COUNTERS
This change has some positive update on query counters when sending mailings
as traces have less unnecessary status update compared to priori this change.
LINKS
Task ID-2377974
Community PR odoo/odoo#61467
Enterprise PR odoo/enterprise#14633
Upgrade PR odoo/upgrade#1907
This commit is contained in:
@@ -98,7 +98,7 @@ class MassMailController(http.Controller):
|
||||
if not consteq(token, tools.hmac(request.env(su=True), 'mass_mailing-mail_mail-open', mail_id)):
|
||||
raise BadRequest()
|
||||
|
||||
request.env['mailing.trace'].sudo().set_opened(mail_mail_ids=[mail_id])
|
||||
request.env['mailing.trace'].sudo().set_opened(domain=[('mail_mail_id_int', 'in', [mail_id])])
|
||||
response = werkzeug.wrappers.Response()
|
||||
response.mimetype = 'image/gif'
|
||||
response.data = base64.b64decode(b'R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==')
|
||||
|
||||
@@ -184,9 +184,11 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_7"/>
|
||||
<field name="email">billy.fox45@example.com</field>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="opened" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="replied" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">reply</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="open_datetime" eval="(DateTime.today() - relativedelta(days=4)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="reply_datetime" eval="(DateTime.today() - relativedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_1" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -194,9 +196,11 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_13"/>
|
||||
<field name="email">kim.snyder96@example.com</field>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="opened" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="replied" eval="(DateTime.today() - relativedelta(days=0)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">reply</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="open_datetime" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="reply_datetime" eval="(DateTime.today() - relativedelta(days=0)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=0)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_2" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -204,8 +208,10 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_14"/>
|
||||
<field name="email">edith.sanchez68@example.com</field>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="opened" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">open</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="open_datetime" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_3" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -213,8 +219,10 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_24"/>
|
||||
<field name="email">theodore.gardner36@example.com</field>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="opened" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">open</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="open_datetime" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_4" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -222,7 +230,9 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_32"/>
|
||||
<field name="email">sandra.neal80@example.com</field>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_5" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -230,8 +240,8 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_33"/>
|
||||
<field name="email">julie.richards84@example.com</field>
|
||||
<field name="sent" eval="False"/>
|
||||
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="sent_datetime" eval="False"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_6" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -239,8 +249,9 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_34"/>
|
||||
<field name="email">travis.mendoza24@example.com</field>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="bounced" eval="(DateTime.today() - relativedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">bounce</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mass_mail_1_stat_7" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mass_mail_1"/>
|
||||
@@ -248,8 +259,8 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_34"/>
|
||||
<field name="email">travis.mendoza24@example.com</field>
|
||||
<field name="sent" eval="False"/>
|
||||
<field name="ignored" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">bounce</field>
|
||||
<field name="sent_datetime" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Generate some clicks -->
|
||||
|
||||
@@ -81,7 +81,7 @@ class MailMail(models.Model):
|
||||
for mail in self:
|
||||
if mail.mailing_id:
|
||||
if mail_sent is True and mail.mailing_trace_ids:
|
||||
mail.mailing_trace_ids.write({'sent': fields.Datetime.now(), 'exception': False})
|
||||
mail.mailing_trace_ids.set_sent()
|
||||
elif mail_sent is False and mail.mailing_trace_ids:
|
||||
mail.mailing_trace_ids.write({'exception': fields.Datetime.now(), 'failure_type': failure_type})
|
||||
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)
|
||||
|
||||
@@ -23,8 +23,8 @@ class MailThread(models.AbstractModel):
|
||||
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(mail_message_ids=msg_references)
|
||||
self.env['mailing.trace'].set_replied(mail_message_ids=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):
|
||||
@@ -53,10 +53,10 @@ class MailThread(models.AbstractModel):
|
||||
bounced_partner = message_dict['bounced_partner']
|
||||
|
||||
if bounced_msg_id:
|
||||
self.env['mailing.trace'].set_bounced(mail_message_ids=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(['&', ('bounced', '>', three_months_ago), ('email', '=ilike', bounced_email)]).mapped('bounced')
|
||||
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)
|
||||
|
||||
@@ -143,7 +143,7 @@ class MassMailing(models.Model):
|
||||
total = fields.Integer(compute="_compute_total")
|
||||
scheduled = fields.Integer(compute="_compute_statistics")
|
||||
expected = fields.Integer(compute="_compute_statistics")
|
||||
ignored = fields.Integer(compute="_compute_statistics")
|
||||
canceled = fields.Integer(compute="_compute_statistics")
|
||||
sent = fields.Integer(compute="_compute_statistics")
|
||||
delivered = fields.Integer(compute="_compute_statistics")
|
||||
opened = fields.Integer(compute="_compute_statistics")
|
||||
@@ -203,7 +203,7 @@ class MassMailing(models.Model):
|
||||
def _compute_statistics(self):
|
||||
""" Compute statistics of the mass mailing """
|
||||
for key in (
|
||||
'scheduled', 'expected', 'ignored', 'sent', 'delivered', 'opened',
|
||||
'scheduled', 'expected', 'canceled', 'sent', 'delivered', 'opened',
|
||||
'clicked', 'replied', 'bounced', 'failed', 'received_ratio',
|
||||
'opened_ratio', 'replied_ratio', 'bounced_ratio',
|
||||
):
|
||||
@@ -216,15 +216,15 @@ class MassMailing(models.Model):
|
||||
SELECT
|
||||
m.id as mailing_id,
|
||||
COUNT(s.id) AS expected,
|
||||
COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
|
||||
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is null AND s.bounced is null THEN 1 ELSE null END) AS scheduled,
|
||||
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is not null THEN 1 ELSE null END) AS ignored,
|
||||
COUNT(CASE WHEN s.sent is not null AND s.exception is null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
|
||||
COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
|
||||
COUNT(CASE WHEN s.clicked is not null THEN 1 ELSE null END) AS clicked,
|
||||
COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied,
|
||||
COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced,
|
||||
COUNT(CASE WHEN s.exception is not null THEN 1 ELSE null END) AS failed
|
||||
COUNT(s.sent_datetime) AS sent,
|
||||
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'outgoing') AS scheduled,
|
||||
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'cancel') AS canceled,
|
||||
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 opened,
|
||||
COUNT(s.links_click_datetime) AS clicked,
|
||||
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'reply') AS replied,
|
||||
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'bounce') AS bounced,
|
||||
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'error') AS failed
|
||||
FROM
|
||||
mailing_trace s
|
||||
RIGHT JOIN
|
||||
@@ -236,7 +236,7 @@ class MassMailing(models.Model):
|
||||
m.id
|
||||
""", (tuple(self.ids), ))
|
||||
for row in self.env.cr.dictfetchall():
|
||||
total = (row['expected'] - row['ignored']) or 1
|
||||
total = (row['expected'] - row['canceled']) or 1
|
||||
row['received_ratio'] = 100.0 * row['delivered'] / total
|
||||
row['opened_ratio'] = 100.0 * row['opened'] / total
|
||||
row['replied_ratio'] = 100.0 * row['replied'] / total
|
||||
@@ -431,8 +431,8 @@ class MassMailing(models.Model):
|
||||
def action_view_traces_scheduled(self):
|
||||
return self._action_view_traces_filtered('scheduled')
|
||||
|
||||
def action_view_traces_ignored(self):
|
||||
return self._action_view_traces_filtered('ignored')
|
||||
def action_view_traces_canceled(self):
|
||||
return self._action_view_traces_filtered('canceled')
|
||||
|
||||
def action_view_traces_failed(self):
|
||||
return self._action_view_traces_filtered('failed')
|
||||
@@ -460,25 +460,31 @@ class MassMailing(models.Model):
|
||||
}
|
||||
|
||||
def action_view_opened(self):
|
||||
return self._action_view_documents_filtered('opened')
|
||||
return self._action_view_documents_filtered('open')
|
||||
|
||||
def action_view_replied(self):
|
||||
return self._action_view_documents_filtered('replied')
|
||||
return self._action_view_documents_filtered('reply')
|
||||
|
||||
def action_view_bounced(self):
|
||||
return self._action_view_documents_filtered('bounced')
|
||||
return self._action_view_documents_filtered('bounce')
|
||||
|
||||
def action_view_delivered(self):
|
||||
return self._action_view_documents_filtered('delivered')
|
||||
|
||||
def _action_view_documents_filtered(self, view_filter):
|
||||
if view_filter in ('opened', 'replied', 'bounced'):
|
||||
opened_stats = self.mailing_trace_ids.filtered(lambda stat: stat[view_filter])
|
||||
elif view_filter == ('delivered'):
|
||||
opened_stats = self.mailing_trace_ids.filtered(lambda stat: stat.sent and not stat.bounced)
|
||||
if view_filter in ('reply', 'bounce'):
|
||||
found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status == view_filter)
|
||||
elif view_filter == 'open':
|
||||
found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status in ('open', 'reply'))
|
||||
elif view_filter == 'click':
|
||||
found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.links_click_datetime)
|
||||
elif view_filter == 'delivered':
|
||||
found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status in ('sent', 'open', 'reply'))
|
||||
elif view_filter == 'sent':
|
||||
found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.sent_datetime)
|
||||
else:
|
||||
opened_stats = self.env['mailing.trace']
|
||||
res_ids = opened_stats.mapped('res_id')
|
||||
found_traces = self.env['mailing.trace']
|
||||
res_ids = found_traces.mapped('res_id')
|
||||
model_name = self.env['ir.model']._get(self.mailing_model_real).display_name
|
||||
return {
|
||||
'name': model_name,
|
||||
|
||||
@@ -12,9 +12,9 @@ class MailingTrace(models.Model):
|
||||
|
||||
Note:: State management / Error codes / Failure types summary
|
||||
|
||||
* state
|
||||
* trace_status
|
||||
'outgoing', 'sent', 'opened', 'replied',
|
||||
'exception', 'bounced', 'ignored'
|
||||
'error', 'bouce', 'cancel'
|
||||
* failure_type
|
||||
# mass_mailing
|
||||
"SMTP", "RECIPIENT", "BOUNCE", "UNKNOWN", "mail_email_missing"
|
||||
@@ -25,7 +25,7 @@ class MailingTrace(models.Model):
|
||||
'sms_server', 'sms_acc'
|
||||
# mass_mailing_sms mass mode specific codes
|
||||
'sms_blacklist', 'sms_duplicate', 'sms_optout',
|
||||
* ignored:
|
||||
* 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
|
||||
@@ -47,7 +47,7 @@ class MailingTrace(models.Model):
|
||||
_name = 'mailing.trace'
|
||||
_description = 'Mailing Statistics'
|
||||
_rec_name = 'id'
|
||||
_order = 'scheduled DESC'
|
||||
_order = 'create_date DESC'
|
||||
|
||||
trace_type = fields.Selection([('mail', 'Mail')], string='Type', default='mail', required=True)
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
@@ -61,39 +61,30 @@ class MailingTrace(models.Model):
|
||||
index=True,
|
||||
)
|
||||
email = fields.Char(string="Email", help="Normalized email address")
|
||||
message_id = fields.Char(string='Message-ID')
|
||||
message_id = fields.Char(string='Message-ID', help="Technical field for the 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', required=True)
|
||||
# campaign / wave data
|
||||
# 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=True)
|
||||
# Bounce and tracking
|
||||
ignored = fields.Datetime(help='Date when the email has been invalidated. '
|
||||
'Invalid emails are blacklisted, opted-out or invalid email format')
|
||||
scheduled = fields.Datetime(help='Date when the email has been created', default=fields.Datetime.now)
|
||||
sent = fields.Datetime(help='Date when the email has been sent')
|
||||
exception = fields.Datetime(help='Date of technical error leading to the email not being sent')
|
||||
opened = fields.Datetime(help='Date when the email has been opened the first time')
|
||||
replied = fields.Datetime(help='Date when this email has been replied for the first time.')
|
||||
bounced = fields.Datetime(help='Date when this email has bounced.')
|
||||
# Link tracking
|
||||
links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click')
|
||||
clicked = fields.Datetime(help='Date when customer clicked on at least one tracked link')
|
||||
# Status
|
||||
state = fields.Selection(compute="_compute_state",
|
||||
selection=[('outgoing', 'Outgoing'),
|
||||
('exception', 'Exception'),
|
||||
('sent', 'Sent'),
|
||||
('opened', 'Opened'),
|
||||
('replied', 'Replied'),
|
||||
('bounced', 'Bounced'),
|
||||
('ignored', 'Ignored')], store=True)
|
||||
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=[
|
||||
("SMTP", "Connection failed (outgoing mail server problem)"),
|
||||
("RECIPIENT", "Invalid email address"),
|
||||
@@ -105,34 +96,15 @@ class MailingTrace(models.Model):
|
||||
("mail_optout", "Opted Out"),
|
||||
("mail_dup", "Duplicated Email"),
|
||||
], string='Failure type')
|
||||
state_update = fields.Datetime(compute="_compute_state", string='State Update',
|
||||
help='Last state update of the mail',
|
||||
store=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.')
|
||||
|
||||
@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.depends('sent', 'opened', 'clicked', 'replied', 'bounced', 'exception', 'ignored')
|
||||
def _compute_state(self):
|
||||
self.update({'state_update': fields.Datetime.now()})
|
||||
for stat in self:
|
||||
if stat.ignored:
|
||||
stat.state = 'ignored'
|
||||
elif stat.exception:
|
||||
stat.state = 'exception'
|
||||
elif stat.replied:
|
||||
stat.state = 'replied'
|
||||
elif stat.opened or stat.clicked:
|
||||
stat.state = 'opened'
|
||||
elif stat.bounced:
|
||||
stat.state = 'bounced'
|
||||
elif stat.sent:
|
||||
stat.state = 'sent'
|
||||
else:
|
||||
stat.state = 'outgoing'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values_list):
|
||||
for values in values_list:
|
||||
@@ -150,33 +122,37 @@ class MailingTrace(models.Model):
|
||||
'res_id': self.res_id
|
||||
}
|
||||
|
||||
def _get_records(self, mail_mail_ids=None, mail_message_ids=None, domain=None):
|
||||
if not self.ids and mail_mail_ids:
|
||||
base_domain = [('mail_mail_id_int', 'in', mail_mail_ids)]
|
||||
elif not self.ids and mail_message_ids:
|
||||
base_domain = [('message_id', 'in', mail_message_ids)]
|
||||
else:
|
||||
base_domain = [('id', 'in', self.ids)]
|
||||
if domain:
|
||||
base_domain = ['&'] + domain + base_domain
|
||||
return self.search(base_domain)
|
||||
|
||||
def set_opened(self, mail_mail_ids=None, mail_message_ids=None):
|
||||
traces = self._get_records(mail_mail_ids, mail_message_ids, [('opened', '=', False)])
|
||||
traces.write({'opened': fields.Datetime.now(), 'bounced': False})
|
||||
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_clicked(self, mail_mail_ids=None, mail_message_ids=None):
|
||||
traces = self._get_records(mail_mail_ids, mail_message_ids, [('clicked', '=', False)])
|
||||
traces.write({'clicked': fields.Datetime.now()})
|
||||
def set_opened(self, domain=None):
|
||||
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
||||
traces.write({'trace_status': 'open', 'open_datetime': fields.Datetime.now()})
|
||||
return traces
|
||||
|
||||
def set_replied(self, mail_mail_ids=None, mail_message_ids=None):
|
||||
traces = self._get_records(mail_mail_ids, mail_message_ids, [('replied', '=', False)])
|
||||
traces.write({'replied': fields.Datetime.now()})
|
||||
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_bounced(self, mail_mail_ids=None, mail_message_ids=None):
|
||||
traces = self._get_records(mail_mail_ids, mail_message_ids, [('bounced', '=', False), ('opened', '=', False)])
|
||||
traces.write({'bounced': fields.Datetime.now()})
|
||||
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
|
||||
|
||||
@@ -47,12 +47,12 @@ class UtmCampaign(models.Model):
|
||||
SELECT
|
||||
c.id as campaign_id,
|
||||
COUNT(s.id) AS expected,
|
||||
COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
|
||||
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is not null THEN 1 ELSE null END) AS ignored,
|
||||
COUNT(CASE WHEN s.id is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
|
||||
COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
|
||||
COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied,
|
||||
COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
|
||||
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
|
||||
@@ -75,13 +75,13 @@ class UtmCampaign(models.Model):
|
||||
if not stats:
|
||||
vals = default_vals
|
||||
else:
|
||||
total = (stats['expected'] - stats['ignored']) or 1
|
||||
delivered = stats['sent'] - stats['bounced']
|
||||
total = (stats['expected'] - stats['cancel']) or 1
|
||||
delivered = stats['sent'] - stats['bounce']
|
||||
vals = {
|
||||
'received_ratio': 100.0 * delivered / total,
|
||||
'opened_ratio': 100.0 * stats['opened'] / total,
|
||||
'replied_ratio': 100.0 * stats['replied'] / total,
|
||||
'bounced_ratio': 100.0 * stats['bounced'] / 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)
|
||||
|
||||
@@ -21,10 +21,12 @@ class MailingTraceReport(models.Model):
|
||||
# traces
|
||||
sent = fields.Integer(readonly=True)
|
||||
delivered = fields.Integer(readonly=True)
|
||||
error = fields.Integer(readonly=True)
|
||||
opened = fields.Integer(readonly=True)
|
||||
replied = fields.Integer(readonly=True)
|
||||
clicked = fields.Integer(readonly=True)
|
||||
bounced = fields.Integer(readonly=True)
|
||||
canceled = fields.Integer(readonly=True)
|
||||
clicked = fields.Integer(readonly=True)
|
||||
|
||||
def init(self):
|
||||
"""Mass Mail Statistical Report: based on mailing.trace that models the various
|
||||
@@ -52,23 +54,25 @@ class MailingTraceReport(models.Model):
|
||||
'utm_source.name as name',
|
||||
'mailing.mailing_type',
|
||||
'utm_campaign.name as campaign',
|
||||
'trace.scheduled as scheduled_date',
|
||||
'trace.create_date as scheduled_date',
|
||||
'mailing.state',
|
||||
'mailing.email_from',
|
||||
'count(trace.sent) as sent',
|
||||
'(count(trace.sent) - count(trace.bounced)) as delivered',
|
||||
'count(trace.opened) as opened',
|
||||
'count(trace.replied) as replied',
|
||||
'count(trace.clicked) as clicked',
|
||||
'count(trace.bounced) as bounced'
|
||||
'COUNT(trace.sent_datetime) as sent',
|
||||
"(COUNT(trace.sent_datetime) - COUNT(trace.trace_status) FILTER (WHERE trace.trace_status IN ('error', 'bounce', 'cancel'))) as delivered",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'error') as error",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'bounce') as bounced",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'cancel') as cancel",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'open') as opened",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'reply') as replied",
|
||||
"COUNT(trace.links_click_datetime) as clicked",
|
||||
]
|
||||
|
||||
def _report_get_request_from_items(self):
|
||||
return [
|
||||
'mailing_trace as trace',
|
||||
'left join mailing_mailing as mailing ON (trace.mass_mailing_id=mailing.id)',
|
||||
'left join utm_campaign as utm_campaign ON (mailing.campaign_id = utm_campaign.id)',
|
||||
'left join utm_source as utm_source ON (mailing.source_id = utm_source.id)'
|
||||
'LEFT JOIN mailing_mailing as mailing ON (trace.mass_mailing_id=mailing.id)',
|
||||
'LEFT JOIN utm_campaign as utm_campaign ON (mailing.campaign_id = utm_campaign.id)',
|
||||
'LEFT JOIN utm_source as utm_source ON (mailing.source_id = utm_source.id)'
|
||||
]
|
||||
|
||||
def _report_get_request_where_items(self):
|
||||
@@ -76,7 +80,7 @@ class MailingTraceReport(models.Model):
|
||||
|
||||
def _report_get_request_group_by_items(self):
|
||||
return [
|
||||
'trace.scheduled',
|
||||
'trace.create_date',
|
||||
'utm_source.name',
|
||||
'utm_campaign.name',
|
||||
'mailing.mailing_type',
|
||||
|
||||
@@ -6,9 +6,13 @@ import random
|
||||
import re
|
||||
import werkzeug
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo import tools
|
||||
from odoo.addons.link_tracker.tests.common import MockLinkTracker
|
||||
from odoo.addons.mail.tests.common import MailCase, MailCommon, mail_new_test_user
|
||||
from odoo import tools
|
||||
from odoo.sql_db import Cursor
|
||||
|
||||
|
||||
class MassMailCase(MailCase, MockLinkTracker):
|
||||
|
||||
@@ -25,7 +29,7 @@ class MassMailCase(MailCase, MockLinkTracker):
|
||||
kwargs['delivered'] = len(mailing.mailing_trace_ids)
|
||||
for fname in ['scheduled', 'expected', 'sent', 'delivered',
|
||||
'opened', 'replied', 'clicked',
|
||||
'ignored', 'failed', 'bounced']:
|
||||
'canceled', 'failed', 'bounced']:
|
||||
self.assertEqual(
|
||||
mailing[fname], kwargs.get(fname, 0),
|
||||
'Mailing %s statistics failed: got %s instead of %s' % (fname, mailing[fname], kwargs.get(fname, 0))
|
||||
@@ -44,7 +48,7 @@ class MassMailCase(MailCase, MockLinkTracker):
|
||||
# TRACE
|
||||
'partner': res.partner record (may be empty),
|
||||
'email': email used when sending email (may be empty, computed based on partner),
|
||||
'state': outgoing / sent / ignored / bounced / exception / opened (sent by default),
|
||||
'trace_status': outgoing / sent / open / reply / bounce / error / cancel (sent by default),
|
||||
'record: linked record,
|
||||
# MAIL.MAIL
|
||||
'content': optional content that should be present in mail.mail body_html;
|
||||
@@ -67,12 +71,11 @@ class MassMailCase(MailCase, MockLinkTracker):
|
||||
# map trace state to email state
|
||||
state_mapping = {
|
||||
'sent': 'sent',
|
||||
'opened': 'sent', # opened implies something has been sent
|
||||
'replied': 'sent', # replied implies something has been sent
|
||||
'ignored': 'cancel',
|
||||
'exception': 'exception',
|
||||
'canceled': 'cancel',
|
||||
'bounced': 'cancel',
|
||||
'open': 'sent', # opened implies something has been sent
|
||||
'reply': 'sent', # replied implies something has been sent
|
||||
'error': 'exception',
|
||||
'cancel': 'cancel',
|
||||
'bounce': 'cancel',
|
||||
}
|
||||
|
||||
traces = self.env['mailing.trace'].search([
|
||||
@@ -90,21 +93,23 @@ class MassMailCase(MailCase, MockLinkTracker):
|
||||
for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records):
|
||||
partner = recipient_info.get('partner', self.env['res.partner'])
|
||||
email = recipient_info.get('email')
|
||||
state = recipient_info.get('state', 'sent')
|
||||
status = recipient_info.get('trace_status', 'sent')
|
||||
record = record or recipient_info.get('record')
|
||||
content = recipient_info.get('content')
|
||||
if email is None and partner:
|
||||
email = partner.email_normalized
|
||||
|
||||
recipient_trace = traces.filtered(
|
||||
lambda t: t.email == email and t.state == state and (t.res_id == record.id if record else True)
|
||||
lambda t: (t.email == email or (not email and not t.email)) and \
|
||||
t.trace_status == status and \
|
||||
(t.res_id == record.id if record else True)
|
||||
)
|
||||
self.assertTrue(
|
||||
len(recipient_trace) == 1,
|
||||
'MailTrace: email %s (recipient %s, state: %s, record: %s): found %s records (1 expected)' % (email, partner, state, record, len(recipient_trace))
|
||||
'MailTrace: email %s (recipient %s, status: %s, record: %s): found %s records (1 expected)' % (email, partner, status, record, len(recipient_trace))
|
||||
)
|
||||
self.assertTrue(bool(recipient_trace.mail_mail_id_int))
|
||||
if 'failure_type' in recipient_info or state in ('ignored', 'exception', 'canceled', 'bounced'):
|
||||
if 'failure_type' in recipient_info or status in ('error', 'cancel', 'bounce'):
|
||||
self.assertEqual(recipient_trace.failure_type, recipient_info['failure_type'])
|
||||
|
||||
if check_mail:
|
||||
@@ -116,15 +121,15 @@ class MassMailCase(MailCase, MockLinkTracker):
|
||||
|
||||
# specific for partner: email_formatted is used
|
||||
if partner:
|
||||
if state == 'sent' and sent_unlink:
|
||||
if status == 'sent' and sent_unlink:
|
||||
self.assertSentEmail(author, [partner])
|
||||
else:
|
||||
self.assertMailMail(partner, state_mapping[state], author=author, content=content, fields_values=fields_values)
|
||||
self.assertMailMail(partner, state_mapping[status], author=author, content=content, fields_values=fields_values)
|
||||
# specific if email is False -> could have troubles finding it if several falsy traces
|
||||
elif not email and state in ('ignored', 'canceled', 'bounced'):
|
||||
self.assertMailMailWId(recipient_trace.mail_mail_id_int, state_mapping[state], content=content, fields_values=fields_values)
|
||||
elif not email and status in ('cancel', 'bounce'):
|
||||
self.assertMailMailWId(recipient_trace.mail_mail_id_int, state_mapping[status], content=content, fields_values=fields_values)
|
||||
else:
|
||||
self.assertMailMailWEmails([email], state_mapping[state], author=author, content=content, fields_values=fields_values)
|
||||
self.assertMailMailWEmails([email], state_mapping[status], author=author, content=content, fields_values=fields_values)
|
||||
|
||||
if link_info:
|
||||
trace_mail = self._find_mail_mail_wrecord(record)
|
||||
@@ -200,15 +205,18 @@ class MassMailCase(MailCase, MockLinkTracker):
|
||||
if dt is None:
|
||||
dt = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
randomized = random.random()
|
||||
trace = cls.env['mailing.trace'].create({
|
||||
'mass_mailing_id': mailing.id,
|
||||
'model': record._name,
|
||||
'res_id': record.id,
|
||||
'bounced': dt,
|
||||
# TDE FIXME: improve this with a mail-enabled heuristics
|
||||
'email': trace_email,
|
||||
'message_id': '<%5f@gilbert.boitempomils>' % randomized,
|
||||
})
|
||||
# Cursor.now() uses transaction's timestamp and not datetime lib -> freeze_time
|
||||
# is not sufficient
|
||||
with patch.object(Cursor, 'now', lambda *args, **kwargs: dt):
|
||||
trace = cls.env['mailing.trace'].sudo().create({
|
||||
'mass_mailing_id': mailing.id,
|
||||
'model': record._name,
|
||||
'res_id': record.id,
|
||||
'trace_status': 'bounce',
|
||||
# TDE FIXME: improve this with a mail-enabled heuristics
|
||||
'email': trace_email,
|
||||
'message_id': '<%5f@gilbert.boitempomils>' % randomized,
|
||||
})
|
||||
return trace
|
||||
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ class TestMassMailFeatures(MassMailCommon, CronMixinCase):
|
||||
|
||||
self.assertMailTraces(
|
||||
[{'partner': partner_a},
|
||||
{'partner': partner_b, 'state': 'ignored', 'failure_type': 'mail_bl'}],
|
||||
{'partner': partner_b, 'trace_status': 'cancel', 'failure_type': 'mail_bl'}],
|
||||
mailing, partner_a + partner_b, check_mail=True
|
||||
)
|
||||
|
||||
|
||||
@@ -66,14 +66,14 @@
|
||||
<field name="state" readonly="1" widget="statusbar"/>
|
||||
</header>
|
||||
<div class="alert alert-info text-center" role="alert"
|
||||
attrs="{'invisible': ['&','&','&','&','&',('state', '!=', 'in_queue'),('sent', '=', 0),('ignored', '=', 0),('scheduled', '=', 0),('failed', '=', 0),('warning_message', '=', False)]}">
|
||||
<div class="o_mails_ignored" attrs="{'invisible': [('ignored', '=', 0)]}">
|
||||
attrs="{'invisible': ['&','&','&','&','&',('state', '!=', 'in_queue'),('sent', '=', 0),('canceled', '=', 0),('scheduled', '=', 0),('failed', '=', 0),('warning_message', '=', False)]}">
|
||||
<div class="o_mails_canceled" attrs="{'invisible': [('canceled', '=', 0)]}">
|
||||
<button class="btn-link py-0"
|
||||
name="action_view_traces_ignored"
|
||||
name="action_view_traces_canceled"
|
||||
type="object">
|
||||
<strong>
|
||||
<field name="ignored" class="oe_inline mr-2"/>
|
||||
<span name="ignored_text">emails have been ignored and will not be sent.</span>
|
||||
<field name="canceled" class="oe_inline mr-2"/>
|
||||
<span name="canceled_text">emails have been canceled and will not be sent.</span>
|
||||
</strong>
|
||||
</button>
|
||||
</div>
|
||||
@@ -339,7 +339,7 @@
|
||||
<xpath expr="//div[hasclass('alert-info')]" position="attributes">
|
||||
<attribute name="attrs">{'invisible': ['|',('state', '=', 'draft'),'&','&',('state', '!=', 'in_queue'),('scheduled', '=', 0),('failed', '=', 0)]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('alert-info')]/div[hasclass('o_mails_ignored')]" position="replace"/>
|
||||
<xpath expr="//div[hasclass('alert-info')]/div[hasclass('o_mails_canceled')]" position="replace"/>
|
||||
<xpath expr="//div[hasclass('alert-info')]/div[hasclass('o_mails_sent')]" position="replace"/>
|
||||
<xpath expr="//div[hasclass('alert-info')]/div[hasclass('o_mails_failed')]//span[@name='failed_text']" position="replace">
|
||||
<span name="failed_text">email(s) not sent.</span>
|
||||
@@ -374,10 +374,10 @@
|
||||
type="object" class="oe_stat_button" icon="fa-paper-plane">
|
||||
<field name="sent" widget="statinfo" string="Sent"/>
|
||||
</button>
|
||||
<button name="action_view_traces_ignored"
|
||||
<button name="action_view_traces_canceled"
|
||||
attrs="{'invisible': [('state', 'in', ('draft','test'))]}"
|
||||
type="object" class="oe_stat_button" icon="fa-paper-plane-o">
|
||||
<field name="ignored" widget="statinfo" string="Ignored"/>
|
||||
<field name="canceled" widget="statinfo" string="Ignored"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
|
||||
@@ -10,22 +10,22 @@
|
||||
<field name="message_id"/>
|
||||
<field name="email"/>
|
||||
<field name="mass_mailing_id"/>
|
||||
<filter string="Scheduled" name="filter_scheduled" domain="[('scheduled', '!=', False), ('sent', '=', False), ('exception', '=', False), ('ignored', '=', False), ('bounced', '=', False)]"/>
|
||||
<filter string="Ignored" name="filter_ignored" domain="[('scheduled', '!=', False), ('sent', '=', False), ('exception', '=', False), ('ignored', '!=', False)]"/>
|
||||
<filter string="Sent" name="filter_sent" domain="[('sent', '!=', False)]"/>
|
||||
<filter string="Delivered" name="filter_delivered" domain="[('sent', '!=', False), ('exception', '=', False), ('bounced', '=', False)]"/>
|
||||
<filter string="Scheduled" name="filter_scheduled" domain="[('trace_status', '=', 'outgoing')]"/>
|
||||
<filter string="Canceled" name="filter_canceled" domain="[('trace_status', '=', 'cancel')]"/>
|
||||
<filter string="Sent" name="filter_sent" domain="[('sent_datetime', '!=', False)]"/>
|
||||
<filter string="Delivered" name="filter_delivered" domain="[('sent_datetime', '!=', False), ('trace_status', 'not in', ['error', 'cancel'])]"/>
|
||||
<separator/>
|
||||
<filter string="Opened" name="filter_opened" domain="[('opened', '!=', False)]"/>
|
||||
<filter string="Clicked" name="filter_clicked" domain="[('clicked', '!=', False)]"/>
|
||||
<filter string="Replied" name="filter_replied" domain="[('replied', '!=', False)]"/>
|
||||
<filter string="Bounced" name="filter_bounced" domain="[('bounced', '!=', False)]"/>
|
||||
<filter string="Failed" name="filter_failed" domain="[('exception', '!=', False)]"/>
|
||||
<filter string="Opened" name="filter_opened" domain="[('trace_status', 'in', ['open', 'reply'])]"/>
|
||||
<filter string="Clicked" name="filter_clicked" domain="[('links_click_datetime', '!=', False)]"/>
|
||||
<filter string="Replied" name="filter_replied" domain="[('trace_status', '=', 'reply')]"/>
|
||||
<filter string="Bounced" name="filter_bounced" domain="[('trace_status', '=', 'bounce')]"/>
|
||||
<filter string="Failed" name="filter_failed" domain="[('trace_status', '=', 'error')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="State" name="state" domain="[]" context="{'group_by':'state'}"/>
|
||||
<filter string="Open Date" name="group_open_date" context="{'group_by': 'opened:day'}"/>
|
||||
<filter string="Reply Date" name="group_reply_date" context="{'group_by': 'replied:day'}"/>
|
||||
<filter string="Last State Update" name="state_update" domain="[]" context="{'group_by':'state_update'}"/>
|
||||
<filter string="Mass Mailing" name="mass_mailing" domain="[]" context="{'group_by':'mass_mailing_id'}"/>
|
||||
<filter string="State" name="state" domain="[]" context="{'group_by': 'trace_status'}"/>
|
||||
<filter string="Open Date" name="group_open_date" domain="[('trace_status', 'in', ['open', 'reply'])]" context="{'group_by': 'open_datetime:day'}"/>
|
||||
<filter string="Reply Date" name="group_reply_date" domain="[('trace_status', '=', 'reply')]" context="{'group_by': 'reply_datetime:day'}"/>
|
||||
<filter string="Last State Update" name="state_update" domain="[]" context="{'group_by': 'write_date'}"/>
|
||||
<filter string="Mass Mailing" name="mass_mailing" domain="[]" context="{'group_by': 'mass_mailing_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
@@ -38,16 +38,13 @@
|
||||
<tree string="Mail Statistics" create="0">
|
||||
<field name="mass_mailing_id"/>
|
||||
<field name="email"/>
|
||||
<field name="state"/>
|
||||
<field name="message_id"/>
|
||||
<field name="scheduled"/>
|
||||
<field name="sent"/>
|
||||
<field name="exception"/>
|
||||
<field name="opened"/>
|
||||
<field name="clicked"/>
|
||||
<field name="replied"/>
|
||||
<field name="bounced"/>
|
||||
<field name="ignored"/>
|
||||
<field name="sent_datetime"/>
|
||||
<field name="trace_status"/>
|
||||
<field name="open_datetime" optional="hide"/>
|
||||
<field name="reply_datetime" optional="hide"/>
|
||||
<field name="links_click_datetime" optional="hide"/>
|
||||
<field name="failure_type"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -58,7 +55,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Mail Statistics" create="0" edit="0">
|
||||
<header>
|
||||
<field name="state" widget="statusbar"/>
|
||||
<field name="trace_status" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
@@ -71,24 +68,19 @@
|
||||
<group string="Mailing">
|
||||
<field name="trace_type" invisible="1"/>
|
||||
<field name="email" string="Recipient Address" readonly="1"/>
|
||||
<field name="sent" attrs="{'invisible' : [('sent', '=', False)]}" readonly="1"/>
|
||||
<field name="opened" attrs="{'invisible' : [('opened', '=', False)]}" readonly="1"/>
|
||||
<field name="replied" attrs="{'invisible' : [('replied', '=', False)]}" readonly="1"/>
|
||||
<field name="bounced" attrs="{'invisible' : [('bounced', '=', False)]}" readonly="1"/>
|
||||
<field name="ignored" attrs="{'invisible' : [('ignored', '=', False)]}" readonly="1"/>
|
||||
<field name="sent_datetime" attrs="{'invisible' : [('sent_datetime', '=', False)]}" readonly="1"/>
|
||||
<field name="open_datetime" attrs="{'invisible' : [('open_datetime', '=', False)]}" readonly="1"/>
|
||||
<field name="reply_datetime" attrs="{'invisible' : [('reply_datetime', '=', False)]}" readonly="1"/>
|
||||
<field name="failure_type" attrs="{'invisible' : [('failure_type', '=', False)]}" readonly="1"/>
|
||||
</group>
|
||||
|
||||
<group string="Marketing">
|
||||
<field name="campaign_id" readonly="1"/>
|
||||
<field name="medium_id" readonly="1"/>
|
||||
<field name="source_id" readonly="1"/>
|
||||
<field name="mass_mailing_id" readonly="1"/>
|
||||
<field name="links_click_datetime" readonly="1"/>
|
||||
</group>
|
||||
|
||||
<group string="Technical">
|
||||
<field name="exception" readonly="1"/>
|
||||
<field name="state_update" readonly="1"/>
|
||||
<field name="mail_mail_id_int" string="Mail ID" readonly="1"/>
|
||||
<field name="message_id" readonly="1"/>
|
||||
</group>
|
||||
@@ -103,8 +95,8 @@
|
||||
<field name="model">mailing.trace</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Mail Statistics" type="bar" stacked="True" sample="1">
|
||||
<field name="state_update" type="row" interval="day"/>
|
||||
<field name="state" type="row"/>
|
||||
<field name="write_date" type="row" interval="day"/>
|
||||
<field name="trace_status" type="row"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -17,6 +17,7 @@ class MailComposeMessage(models.TransientModel):
|
||||
""" Override method that generated the mail content by creating the
|
||||
mailing.trace values in the o2m of mail_mail, when doing pure
|
||||
email mass mailing. """
|
||||
now = fields.Datetime.now()
|
||||
self.ensure_one()
|
||||
res = super(MailComposeMessage, self).get_mail_values(res_ids)
|
||||
# use only for allowed models in mass mailing
|
||||
@@ -32,7 +33,7 @@ class MailComposeMessage(models.TransientModel):
|
||||
'state': 'done',
|
||||
'reply_to_mode': self.reply_to_mode,
|
||||
'reply_to': self.reply_to if self.reply_to_mode == 'new' else False,
|
||||
'sent_date': fields.Datetime.now(),
|
||||
'sent_date': now,
|
||||
'body_html': self.body,
|
||||
'mailing_model_id': self.env['ir.model']._get(self.model).id,
|
||||
'mailing_domain': self.active_domain,
|
||||
@@ -98,9 +99,9 @@ class MailComposeMessage(models.TransientModel):
|
||||
}
|
||||
# propagate failed states to trace when still-born
|
||||
if mail_values.get('state') == 'cancel':
|
||||
trace_vals['ignored'] = fields.Datetime.now()
|
||||
trace_vals['trace_status'] = 'cancel'
|
||||
elif mail_values.get('state') == 'exception':
|
||||
trace_vals['exception'] = fields.Datetime.now()
|
||||
trace_vals['trace_status'] = 'error'
|
||||
if error_code:
|
||||
trace_vals['failure_type'] = error_code
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
<field name="sms_number">+32465000000</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_7"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_0_trace_1" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_0"/>
|
||||
@@ -29,7 +31,9 @@
|
||||
<field name="sms_number">+32465000001</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_13"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_0_trace_2" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_0"/>
|
||||
@@ -37,7 +41,9 @@
|
||||
<field name="sms_number">+32465000002</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_14"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_0_trace_3" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_0"/>
|
||||
@@ -45,7 +51,9 @@
|
||||
<field name="sms_number">+32465000003</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_24"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_0_trace_4" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_0"/>
|
||||
@@ -53,8 +61,10 @@
|
||||
<field name="sms_number">+32465000004</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_33"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_server</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_0_trace_5" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_0"/>
|
||||
@@ -62,8 +72,9 @@
|
||||
<field name="sms_number">123123</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_33"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="bounced" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">bounce</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="write_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_0_trace_6" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_0"/>
|
||||
@@ -71,8 +82,9 @@
|
||||
<field name="sms_number"></field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_34"/>
|
||||
<field name="sent" eval="False"/>
|
||||
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_number_missing</field>
|
||||
<field name="sent_datetime" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Generate some clicks -->
|
||||
@@ -128,7 +140,8 @@
|
||||
<field name="sms_number">+32456001100</field>
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_0"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_1_trace_1" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_1"/>
|
||||
@@ -136,7 +149,8 @@
|
||||
<field name="sms_number">+32456001111</field>
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_1"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_1_trace_2" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_1"/>
|
||||
@@ -144,7 +158,8 @@
|
||||
<field name="sms_number">+32456001122</field>
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_2"/>
|
||||
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="sent_datetime" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
</record>
|
||||
<record id="mailing_sms_1_trace_3" model="mailing.trace">
|
||||
<field name="mass_mailing_id" ref="mailing_sms_1"/>
|
||||
@@ -152,7 +167,7 @@
|
||||
<field name="sms_number">+32456001133</field>
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_3"/>
|
||||
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_credit</field>
|
||||
</record>
|
||||
<record id="mailing_sms_1_trace_4" model="mailing.trace">
|
||||
@@ -161,7 +176,7 @@
|
||||
<field name="sms_number">dummy</field>
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_4"/>
|
||||
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_number_format</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -53,36 +53,3 @@ class MailingTrace(models.Model):
|
||||
as it serves as obfuscation when unsubscribing. A valid trio
|
||||
code / mailing_id / number will be requested. """
|
||||
return ''.join(random.choice(string.ascii_letters + string.digits) for dummy in range(self.CODE_SIZE))
|
||||
|
||||
def _get_records_from_sms(self, sms_sms_ids=None, additional_domain=None):
|
||||
if not self.ids and sms_sms_ids:
|
||||
domain = [('sms_sms_id_int', 'in', sms_sms_ids)]
|
||||
else:
|
||||
domain = [('id', 'in', self.ids)]
|
||||
if additional_domain:
|
||||
domain = expression.AND([domain, additional_domain])
|
||||
return self.search(domain)
|
||||
|
||||
def set_failed(self, failure_type):
|
||||
for trace in self:
|
||||
trace.write({'exception': fields.Datetime.now(), 'failure_type': failure_type})
|
||||
|
||||
def set_sms_sent(self, sms_sms_ids=None):
|
||||
statistics = self._get_records_from_sms(sms_sms_ids, [('sent', '=', False)])
|
||||
statistics.write({'sent': fields.Datetime.now()})
|
||||
return statistics
|
||||
|
||||
def set_sms_clicked(self, sms_sms_ids=None):
|
||||
statistics = self._get_records_from_sms(sms_sms_ids, [('clicked', '=', False)])
|
||||
statistics.write({'clicked': fields.Datetime.now()})
|
||||
return statistics
|
||||
|
||||
def set_sms_ignored(self, sms_sms_ids=None):
|
||||
statistics = self._get_records_from_sms(sms_sms_ids, [('ignored', '=', False)])
|
||||
statistics.write({'ignored': fields.Datetime.now()})
|
||||
return statistics
|
||||
|
||||
def set_sms_exception(self, sms_sms_ids=None):
|
||||
statistics = self._get_records_from_sms(sms_sms_ids, [('exception', '=', False)])
|
||||
statistics.write({'exception': fields.Datetime.now()})
|
||||
return statistics
|
||||
|
||||
@@ -37,7 +37,7 @@ class SmsSms(models.Model):
|
||||
('sms_sms_id_int', 'in', sms_ids)
|
||||
])
|
||||
if traces and state == 'success':
|
||||
traces.write({'sent': fields.Datetime.now(), 'exception': False})
|
||||
traces.set_sent()
|
||||
elif traces:
|
||||
traces.set_failed(failure_type=self.IAP_TO_SMS_STATE[state])
|
||||
return super(SmsSms, self)._postprocess_iap_sent_sms(iap_results, failure_reason=failure_reason, delete_all=delete_all)
|
||||
|
||||
@@ -31,7 +31,7 @@ class MassSMSCase(SMSCase):
|
||||
# TRACE
|
||||
'partner': res.partner record (may be empty),
|
||||
'number': number used for notification (may be empty, computed based on partner),
|
||||
'state': outgoing / sent / ignored / bounced / exception / opened (outgoing by default),
|
||||
'trace_status': outgoing / sent / cancel / bounce / error / opened (sent by default),
|
||||
'record: linked record,
|
||||
# SMS.SMS
|
||||
'content': optional: if set, check content of sent SMS;
|
||||
@@ -55,9 +55,9 @@ class MassSMSCase(SMSCase):
|
||||
state_mapping = {
|
||||
'sent': 'sent',
|
||||
'outgoing': 'outgoing',
|
||||
'exception': 'error',
|
||||
'ignored': 'canceled',
|
||||
'bounced': 'error',
|
||||
'error': 'error',
|
||||
'cancel': 'canceled',
|
||||
'bounce': 'error',
|
||||
}
|
||||
traces = self.env['mailing.trace'].search([
|
||||
('mass_mailing_id', 'in', mailing.ids),
|
||||
@@ -74,27 +74,27 @@ class MassSMSCase(SMSCase):
|
||||
for recipient_info, link_info, record in zip(recipients_info, sms_links_info, records):
|
||||
partner = recipient_info.get('partner', self.env['res.partner'])
|
||||
number = recipient_info.get('number')
|
||||
state = recipient_info.get('state', 'outgoing')
|
||||
status = recipient_info.get('trace_status', 'outgoing')
|
||||
content = recipient_info.get('content', None)
|
||||
if number is None and partner:
|
||||
number = partner._sms_get_recipients_info()[partner.id]['sanitized']
|
||||
|
||||
trace = traces.filtered(
|
||||
lambda t: t.sms_number == number and t.state == state and (t.res_id == record.id if record else True)
|
||||
lambda t: t.sms_number == number and t.trace_status == status and (t.res_id == record.id if record else True)
|
||||
)
|
||||
self.assertTrue(len(trace) == 1,
|
||||
'SMS: found %s notification for number %s, (state: %s) (1 expected)' % (len(trace), number, state))
|
||||
'SMS: found %s notification for number %s, (status: %s) (1 expected)' % (len(trace), number, status))
|
||||
self.assertTrue(bool(trace.sms_sms_id_int))
|
||||
|
||||
if check_sms:
|
||||
if state == 'sent':
|
||||
if status == 'sent':
|
||||
if sent_unlink:
|
||||
self.assertSMSIapSent([number], content=content)
|
||||
else:
|
||||
self.assertSMS(partner, number, 'sent', content=content)
|
||||
elif state in state_mapping:
|
||||
sms_state = state_mapping[state]
|
||||
error_code = recipient_info['failure_type'] if state in ('exception', 'ignored', 'bounced') else None
|
||||
elif status in state_mapping:
|
||||
sms_state = state_mapping[status]
|
||||
error_code = recipient_info['failure_type'] if status in ('error', 'cancel', 'bounce') else None
|
||||
self.assertSMS(partner, number, sms_state, error_code=error_code, content=content)
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
@@ -103,7 +103,7 @@ class MassSMSCase(SMSCase):
|
||||
# shortened links are directly included in sms.sms record as well as
|
||||
# in sent sms (not like mails who are post-processed)
|
||||
sms_sent = self._find_sms_sent(partner, number)
|
||||
sms_sms = self._find_sms_sms(partner, number, state_mapping[state])
|
||||
sms_sms = self._find_sms_sms(partner, number, state_mapping[status])
|
||||
for (url, is_shortened, add_link_params) in link_info:
|
||||
if url == 'unsubscribe':
|
||||
url = '%s/sms/%d/%s' % (mailing.get_base_url(), mailing.id, trace.sms_code)
|
||||
|
||||
@@ -61,11 +61,11 @@
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='ignored_text']" position="attributes">
|
||||
<xpath expr="//span[@name='canceled_text']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='ignored_text']" position="after">
|
||||
<span name="ignored_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message have been ignored and will not be sent.</span>
|
||||
<xpath expr="//span[@name='canceled_text']" position="after">
|
||||
<span name="canceled_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message have been canceled and will not be sent.</span>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='scheduled_text']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
<field name="priority">20</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="SMS Traces" create="0">
|
||||
<field name="mass_mailing_id"/>
|
||||
<field name="sms_number"/>
|
||||
<field name="state"/>
|
||||
<field name="scheduled"/>
|
||||
<field name="sent"/>
|
||||
<field name="clicked"/>
|
||||
<field name="bounced"/>
|
||||
<field name="exception"/>
|
||||
<field name="ignored"/>
|
||||
<field name="message_id"/>
|
||||
<field name="sent_datetime"/>
|
||||
<field name="trace_status"/>
|
||||
<field name="open_datetime" optional="hide"/>
|
||||
<field name="reply_datetime" optional="hide"/>
|
||||
<field name="links_click_datetime" optional="hide"/>
|
||||
<field name="failure_type"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -60,16 +60,6 @@
|
||||
<xpath expr="//field[@name='message_id']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='opened']" position="attributes">
|
||||
<attribute name="attrs">
|
||||
{'invisible': ['|', ('opened', '=', False), ('trace_type', '=', 'sms')]}
|
||||
</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='replied']" position="attributes">
|
||||
<attribute name="attrs">
|
||||
{'invisible': ['|', ('replied', '=', False), ('trace_type', '=', 'sms')]}
|
||||
</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='email']" position="after">
|
||||
<field name="sms_number" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/>
|
||||
</xpath>
|
||||
@@ -87,13 +77,13 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="SMS Trace" create="0">
|
||||
<header>
|
||||
<field name="state" widget="statusbar"/>
|
||||
<field name="trace_status" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="alert alert-info text-center" attrs="{'invisible': [('exception', '=', False)]}" role="alert">
|
||||
<div class="alert alert-info text-center" attrs="{'invisible': [('trace_status', '!=', 'error')]}" role="alert">
|
||||
<strong>This SMS could not be sent.</strong>
|
||||
</div>
|
||||
<div class="alert alert-info text-center" attrs="{'invisible': [('bounced', '=', False)]}" role="alert">
|
||||
<div class="alert alert-info text-center" attrs="{'invisible': [('trace_status', '!=', 'bounce')]}" role="alert">
|
||||
<strong>This number appears to be invalid.</strong>
|
||||
</div>
|
||||
<group>
|
||||
@@ -106,13 +96,9 @@
|
||||
<field name="res_id" groups="base.group_no_one"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="scheduled"/>
|
||||
<field name="sent"/>
|
||||
<field name="clicked"/>
|
||||
<field name="bounced"/>
|
||||
<field name="exception"/>
|
||||
<field name="failure_type" attrs="{'invisible': [('exception', '=', False)]}"/>
|
||||
<field name="ignored"/>
|
||||
<field name="sent_datetime"/>
|
||||
<field name="links_click_datetime"/>
|
||||
<field name="failure_type" attrs="{'invisible': [('trace_status', '!=', 'error')]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
@@ -36,10 +36,10 @@ class SMSComposer(models.TransientModel):
|
||||
}
|
||||
if sms_values['state'] == 'error':
|
||||
trace_values['failure_type'] = sms_values['error_code']
|
||||
trace_values['exception'] = fields.Datetime.now()
|
||||
trace_values['trace_status'] = 'error'
|
||||
elif sms_values['state'] == 'canceled':
|
||||
trace_values['failure_type'] = sms_values['error_code']
|
||||
trace_values['ignored'] = fields.Datetime.now()
|
||||
trace_values['trace_status'] = 'cancel'
|
||||
else:
|
||||
if self.mass_sms_allow_unsubscribe:
|
||||
sms_values['body'] = '%s\n%s' % (sms_values['body'] or '', _('STOP SMS : %s', self._get_unsubscribe_url(record.id, trace_code, sms_values['number'])))
|
||||
|
||||
@@ -45,25 +45,25 @@ class TestMassMailing(TestMailFullCommon):
|
||||
recipient_info = {
|
||||
'email': recipient.email_normalized,
|
||||
'content': 'Hello %s' % recipient.name}
|
||||
# opt-out: ignored (cancel mail)
|
||||
# opt-out: cancel (cancel mail)
|
||||
if recipient in recipients[1] | recipients[2]:
|
||||
recipient_info['state'] = 'ignored'
|
||||
recipient_info['trace_status'] = "cancel"
|
||||
recipient_info['failure_type'] = "mail_optout"
|
||||
# blacklisted: ignored (cancel mail)
|
||||
# blacklisted: cancel (cancel mail)
|
||||
elif recipient in recipients[3] | recipients[4]:
|
||||
recipient_info['state'] = 'ignored'
|
||||
recipient_info['trace_status'] = "cancel"
|
||||
recipient_info['failure_type'] = "mail_bl"
|
||||
# duplicates: ignored (cancel mail)
|
||||
# duplicates: cancel (cancel mail)
|
||||
elif recipient == recipient_dup_1:
|
||||
recipient_info['state'] = 'ignored'
|
||||
recipient_info['trace_status'] = "cancel"
|
||||
recipient_info['failure_type'] = "mail_dup"
|
||||
# void: error (failed mail)
|
||||
elif recipient == recipient_void_1:
|
||||
recipient_info['state'] = 'ignored'
|
||||
recipient_info['trace_status'] = 'cancel'
|
||||
recipient_info['failure_type'] = "mail_email_missing"
|
||||
# falsy: error (failed mail)
|
||||
elif recipient == recipient_falsy_1:
|
||||
recipient_info['state'] = 'ignored'
|
||||
recipient_info['trace_status'] = "cancel"
|
||||
recipient_info['failure_type'] = "RECIPIENT"
|
||||
recipient_info['email'] = recipient.email_from # normalized is False but email should be falsymail
|
||||
else:
|
||||
@@ -118,4 +118,4 @@ class TestMassMailing(TestMailFullCommon):
|
||||
|
||||
# sent: 13, 2 bl, 2 opt-out, 3 invalid -> 6 remaining
|
||||
# ignored: 2 bl + 2 optout + 2 invalid + 1 duplicate; failed: 0
|
||||
self.assertMailingStatistics(mailing, expected=13, delivered=6, sent=6, ignored=7, failed=0)
|
||||
self.assertMailingStatistics(mailing, expected=13, delivered=6, sent=6, canceled=7, failed=0)
|
||||
|
||||
@@ -169,28 +169,28 @@ class TestMassSMSInternals(TestMassSMSCommon):
|
||||
# duplicates
|
||||
self.assertSMSTraces(
|
||||
[{'partner': new_record_2.customer_id, 'number': self.records_numbers[0],
|
||||
'content': 'Dear %s this is a mass SMS.' % new_record_2.display_name, 'state': 'ignored',
|
||||
'content': 'Dear %s this is a mass SMS.' % new_record_2.display_name, 'trace_status': 'cancel',
|
||||
'failure_type': 'sms_duplicate'}],
|
||||
self.mailing, new_record_2,
|
||||
)
|
||||
# blacklist
|
||||
self.assertSMSTraces(
|
||||
[{'partner': self.env['res.partner'], 'number': phone_validation.phone_format(bl_record_1.phone_nbr, 'BE', '32', force_format='E164'),
|
||||
'content': 'Dear %s this is a mass SMS.' % bl_record_1.display_name, 'state': 'ignored',
|
||||
'content': 'Dear %s this is a mass SMS.' % bl_record_1.display_name, 'trace_status': 'cancel',
|
||||
'failure_type': 'sms_blacklist'}],
|
||||
self.mailing, bl_record_1,
|
||||
)
|
||||
# missing number
|
||||
self.assertSMSTraces(
|
||||
[{'partner': self.env['res.partner'], 'number': False,
|
||||
'content': 'Dear %s this is a mass SMS.' % void_record.display_name, 'state': 'ignored',
|
||||
'content': 'Dear %s this is a mass SMS.' % void_record.display_name, 'trace_status': 'cancel',
|
||||
'failure_type': 'sms_number_missing'}],
|
||||
self.mailing, void_record,
|
||||
)
|
||||
# wrong values
|
||||
self.assertSMSTraces(
|
||||
[{'partner': self.env['res.partner'], 'number': record.phone_nbr,
|
||||
'content': 'Dear %s this is a mass SMS.' % record.display_name, 'state': 'ignored',
|
||||
'content': 'Dear %s this is a mass SMS.' % record.display_name, 'trace_status': 'cancel',
|
||||
'failure_type': 'sms_number_format'}
|
||||
for record in falsy_record_1 + falsy_record_2],
|
||||
self.mailing, falsy_record_1 + falsy_record_2,
|
||||
@@ -220,7 +220,7 @@ class TestMassSMSInternals(TestMassSMSCommon):
|
||||
# new failed traces generated for duplicates
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id, 'number': self.records_numbers[i],
|
||||
'content': 'Dear %s this is a mass SMS.' % record.display_name, 'state': 'ignored',
|
||||
'content': 'Dear %s this is a mass SMS.' % record.display_name, 'trace_status': 'cancel',
|
||||
'failure_type': 'sms_duplicate'}
|
||||
for i, record in enumerate(self.records[:5])],
|
||||
self.mailing, self.records[:5],
|
||||
@@ -282,7 +282,7 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': self.records_numbers[i],
|
||||
'state': 'sent',
|
||||
'trace_status': 'sent',
|
||||
'content': 'Dear %s this is a mass SMS with two links' % record.display_name
|
||||
} for i, record in enumerate(self.records)],
|
||||
mailing, self.records,
|
||||
@@ -317,7 +317,7 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': record.customer_id.phone_sanitized,
|
||||
'state': 'sent',
|
||||
'trace_status': 'sent',
|
||||
'content': 'Dear %s this is a mass SMS with two links' % record.display_name
|
||||
} for record in records],
|
||||
mailing, records,
|
||||
@@ -342,7 +342,7 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'partner': new_record.customer_id,
|
||||
'number': new_record.customer_id.phone_sanitized,
|
||||
'state': 'sent',
|
||||
'trace_status': 'sent',
|
||||
'content': 'Dear %s this is a mass SMS with two links' % new_record.display_name
|
||||
}],
|
||||
mailing, new_record,
|
||||
@@ -395,11 +395,11 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
mailing.action_send_sms()
|
||||
|
||||
self.assertSMSTraces(
|
||||
[{'number': '+32456000000', 'state': 'ignored', 'failure_type': 'sms_optout'},
|
||||
{'number': '+32456000101', 'state': 'ignored', 'failure_type': 'sms_optout'},
|
||||
{'number': '+32456000202', 'state': 'sent'},
|
||||
{'number': '+32456000303', 'state': 'sent'},
|
||||
{'number': '+32456000404', 'state': 'ignored', 'failure_type': 'sms_blacklist'}],
|
||||
[{'number': '+32456000000', 'trace_status': 'cancel', 'failure_type': 'sms_optout'},
|
||||
{'number': '+32456000101', 'trace_status': 'cancel', 'failure_type': 'sms_optout'},
|
||||
{'number': '+32456000202', 'trace_status': 'sent'},
|
||||
{'number': '+32456000303', 'trace_status': 'sent'},
|
||||
{'number': '+32456000404', 'trace_status': 'cancel', 'failure_type': 'sms_blacklist'}],
|
||||
mailing, recipients
|
||||
)
|
||||
self.assertEqual(mailing.ignored, 3)
|
||||
self.assertEqual(mailing.canceled, 3)
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
|
||||
import datetime
|
||||
|
||||
from odoo.addons.test_mass_mailing.tests import common
|
||||
from odoo.tests.common import users
|
||||
from freezegun import freeze_time
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.addons.mass_mailing.models.mail_thread import BLACKLIST_MAX_BOUNCED_LIMIT
|
||||
from odoo.addons.test_mass_mailing.tests import common
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import users
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.sql_db import Cursor
|
||||
|
||||
|
||||
@tagged('mail_blacklist')
|
||||
class TestAutoBlacklist(common.TestMassMailCommon):
|
||||
|
||||
@classmethod
|
||||
@@ -42,15 +49,21 @@ class TestAutoBlacklist(common.TestMassMailCommon):
|
||||
})
|
||||
self._test_mailing_bounce_w_auto_bl({'bounced_partner': bounced_partners})
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_thread')
|
||||
def _test_mailing_bounce_w_auto_bl(self, bounce_base_values):
|
||||
mailing = self.env['mailing.mailing'].browse(self.mailing_bl.ids)
|
||||
target = self.env['mailing.test.blacklist'].browse(self.target_rec.ids)
|
||||
|
||||
# create bounced history of 4 statistics
|
||||
traces = self.env['mailing.trace']
|
||||
for idx in range(4):
|
||||
new_mailing = mailing.copy()
|
||||
self._create_bounce_trace(new_mailing, target, dt=datetime.datetime.now() - datetime.timedelta(weeks=idx+2))
|
||||
self.gateway_mail_bounce(new_mailing, target, bounce_base_values)
|
||||
new_dt = datetime.datetime.now() - datetime.timedelta(weeks=idx+2)
|
||||
# Cursor.now() uses transaction's timestamp and not datetime lib -> freeze_time
|
||||
# is not sufficient
|
||||
with freeze_time(new_dt), patch.object(Cursor, 'now', lambda *args, **kwargs: new_dt):
|
||||
traces += self._create_bounce_trace(new_mailing, target, dt=datetime.datetime.now() - datetime.timedelta(weeks=idx+2))
|
||||
self.gateway_mail_bounce(new_mailing, target, bounce_base_values)
|
||||
|
||||
# mass mail record: ok, not blacklisted yet
|
||||
mailing.action_put_in_queue()
|
||||
@@ -78,6 +91,6 @@ class TestAutoBlacklist(common.TestMassMailCommon):
|
||||
with self.mock_mail_gateway(mail_unlink_sent=False):
|
||||
new_mailing._process_mass_mailing_queue()
|
||||
self.assertMailTraces(
|
||||
[{'email': 'test.record.00@test.example.com', 'state': 'ignored', 'failure_type': 'mail_bl'}],
|
||||
[{'email': 'test.record.00@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_bl'}],
|
||||
new_mailing, target, check_mail=True
|
||||
)
|
||||
|
||||
@@ -48,22 +48,23 @@ class TestLinkTracker(common.TestMassMailCommon):
|
||||
record = self.env['mailing.test.blacklist'].create({})
|
||||
code = self.link.code
|
||||
self.assertEqual(self.link.count, 1)
|
||||
stat = self.env['mailing.trace'].create({
|
||||
trace = self.env['mailing.trace'].create({
|
||||
'mass_mailing_id': self.mailing_bl.id,
|
||||
'model': record._name,
|
||||
'res_id': record.id,
|
||||
})
|
||||
self.assertFalse(stat.opened)
|
||||
self.assertFalse(stat.clicked)
|
||||
self.assertEqual(trace.trace_status, 'outgoing')
|
||||
self.assertFalse(trace.links_click_datetime)
|
||||
|
||||
# click from a new IP should create a new entry and update stat when provided
|
||||
click = self.env['link.tracker.click'].sudo().add_click(
|
||||
code,
|
||||
ip='100.00.00.01',
|
||||
country_code='BEL',
|
||||
mailing_trace_id=stat.id
|
||||
mailing_trace_id=trace.id
|
||||
)
|
||||
self.assertEqual(self.link.count, 2)
|
||||
self.assertEqual(click.mass_mailing_id, self.mailing_bl)
|
||||
self.assertTrue(stat.opened)
|
||||
self.assertTrue(stat.clicked)
|
||||
self.assertTrue(trace.trace_status, 'open')
|
||||
self.assertTrue(trace.links_click_datetime)
|
||||
self.assertEqual(trace.links_click_ids, click)
|
||||
|
||||
@@ -59,11 +59,11 @@ class TestMassMailing(TestMassMailCommon):
|
||||
traces = self.env['mailing.trace'].search([('model', '=', customers._name), ('res_id', 'in', customers.ids)])
|
||||
self.assertEqual(len(traces), 3)
|
||||
customer0_trace = traces.filtered(lambda t: t.res_id == customers[0].id)
|
||||
self.assertEqual(customer0_trace.state, 'replied')
|
||||
self.assertEqual(customer0_trace.trace_status, 'reply')
|
||||
customer1_trace = traces.filtered(lambda t: t.res_id == customers[1].id)
|
||||
self.assertEqual(customer1_trace.state, 'replied')
|
||||
self.assertEqual(customer1_trace.trace_status, 'reply')
|
||||
customer2_trace = traces.filtered(lambda t: t.res_id == customers[2].id)
|
||||
self.assertEqual(customer2_trace.state, 'sent')
|
||||
self.assertEqual(customer2_trace.trace_status, 'sent')
|
||||
|
||||
# check mailing statistics
|
||||
self.assertEqual(mailing.sent, 3)
|
||||
@@ -245,11 +245,11 @@ class TestMassMailing(TestMassMailCommon):
|
||||
[{'email': 'test.record.00@test.example.com'},
|
||||
{'email': 'test.record.01@test.example.com'},
|
||||
{'email': 'test.record.02@test.example.com'},
|
||||
{'email': 'test.record.03@test.example.com', 'state': 'ignored', 'failure_type': 'mail_bl'},
|
||||
{'email': 'test.record.04@test.example.com', 'state': 'ignored', 'failure_type': 'mail_bl'}],
|
||||
{'email': 'test.record.03@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_bl'},
|
||||
{'email': 'test.record.04@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_bl'}],
|
||||
mailing, recipients, check_mail=True
|
||||
)
|
||||
self.assertEqual(mailing.ignored, 2)
|
||||
self.assertEqual(mailing.canceled, 2)
|
||||
|
||||
@users('user_marketing')
|
||||
@mute_logger('odoo.addons.mail.models.mail_mail')
|
||||
@@ -271,14 +271,14 @@ class TestMassMailing(TestMassMailCommon):
|
||||
mailing._process_mass_mailing_queue()
|
||||
|
||||
self.assertMailTraces(
|
||||
[{'email': 'test.record.00@test.example.com', 'state': 'ignored', 'failure_type': 'mail_optout'},
|
||||
{'email': 'test.record.01@test.example.com', 'state': 'ignored', 'failure_type': 'mail_optout'},
|
||||
[{'email': 'test.record.00@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_optout'},
|
||||
{'email': 'test.record.01@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_optout'},
|
||||
{'email': 'test.record.02@test.example.com'},
|
||||
{'email': 'test.record.03@test.example.com'},
|
||||
{'email': 'test.record.04@test.example.com', 'state': 'ignored', 'failure_type': 'mail_bl'}],
|
||||
{'email': 'test.record.04@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_bl'}],
|
||||
mailing, recipients, check_mail=True
|
||||
)
|
||||
self.assertEqual(mailing.ignored, 3)
|
||||
self.assertEqual(mailing.canceled, 3)
|
||||
|
||||
@users('user_marketing')
|
||||
@mute_logger('odoo.addons.mail.models.mail_mail')
|
||||
@@ -331,13 +331,13 @@ class TestMassMailing(TestMassMailCommon):
|
||||
mailing._process_mass_mailing_queue()
|
||||
|
||||
self.assertMailTraces(
|
||||
[{'email': 'test@test.example.com', 'state': 'sent'},
|
||||
{'email': 'test@test.example.com', 'state': 'ignored', 'failure_type': 'mail_dup'},
|
||||
[{'email': 'test@test.example.com', 'trace_status': 'sent'},
|
||||
{'email': 'test@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_dup'},
|
||||
{'email': 'test3@test.example.com'},
|
||||
{'email': 'test4@test.example.com'},
|
||||
{'email': 'test5@test.example.com', 'state': 'ignored', 'failure_type': 'mail_optout'}],
|
||||
{'email': 'test5@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_optout'}],
|
||||
mailing,
|
||||
mailing_contact_1 + mailing_contact_2 + mailing_contact_3 + mailing_contact_4 + mailing_contact_5,
|
||||
check_mail=True
|
||||
)
|
||||
self.assertEqual(mailing.ignored, 2)
|
||||
self.assertEqual(mailing.canceled, 2)
|
||||
|
||||
@@ -50,8 +50,8 @@ class TestMassMailPerformance(TestMassMailPerformanceBase):
|
||||
'mailing_domain': [('id', 'in', self.mm_recs.ids)],
|
||||
})
|
||||
|
||||
# runbot needs +101 compared to local (1616)
|
||||
with self.assertQueryCount(__system__=1717, marketing=1717):
|
||||
# runbot needs +101 compared to local (1567)
|
||||
with self.assertQueryCount(__system__=1668, marketing=1668):
|
||||
mailing.action_send_mail()
|
||||
|
||||
self.assertEqual(mailing.sent, 50)
|
||||
@@ -90,8 +90,8 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase):
|
||||
'mailing_domain': [('id', 'in', self.mm_recs.ids)],
|
||||
})
|
||||
|
||||
# runbot needs +125 compared to local (1882)
|
||||
with self.assertQueryCount(__system__=2007, marketing=2007):
|
||||
# runbot needs +125 compared to local (1833)
|
||||
with self.assertQueryCount(__system__=1958, marketing=1958):
|
||||
mailing.action_send_mail()
|
||||
|
||||
self.assertEqual(mailing.sent, 50)
|
||||
|
||||
Reference in New Issue
Block a user