Currently if someone replies to a mass mailing email, then performs a click on
tracked links, final trace status is set to opened. Indeed both reply and
click set the trace as opened, as replying or clicking imply opening the
mail.
Update is the following
* user replies -> set status to open, then set status to reply, and update
open_datetime and reply_datetime;
* user clicks -> set status to open, then update click_datetime;
In the end the trace is left as opened while it is replied, with a click
datetime attached to the trace. To avoid unnecessary status update, a trace
is set to opened only if it is not already opened or replied. Other statuses
should not override themselves.
Task-2692316
X-original-commit: ce88d364369c8b58256e8d93b7cb506a2c262a34
Part-of: odoo/odoo#79877
165 lines
7.4 KiB
Python
165 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models
|
|
|
|
|
|
class MailingTrace(models.Model):
|
|
""" MailingTrace models the statistics collected about emails. Those statistics
|
|
are stored in a separated model and table to avoid bloating the mail_mail table
|
|
with statistics values. This also allows to delete emails send with mass mailing
|
|
without loosing the statistics about them.
|
|
|
|
Note:: State management / Error codes / Failure types summary
|
|
|
|
* trace_status
|
|
'outgoing', 'sent', 'opened', 'replied',
|
|
'error', 'bouce', 'cancel'
|
|
* failure_type
|
|
# generic
|
|
'unknown',
|
|
# mass_mailing
|
|
"mail_email_invalid", "mail_smtp", "mail_email_missing"
|
|
# mass mailing mass mode specific codes
|
|
"mail_bl", "mail_optout", "mail_dup"
|
|
# mass_mailing_sms
|
|
'sms_number_missing', 'sms_number_format', 'sms_credit',
|
|
'sms_server', 'sms_acc'
|
|
# mass_mailing_sms mass mode specific codes
|
|
'sms_blacklist', 'sms_duplicate', 'sms_optout',
|
|
* cancel:
|
|
* mail: set in get_mail_values in composer, if email is blacklisted
|
|
(mail) or in opt_out / seen list (mass_mailing) or email_to is void
|
|
or incorrectly formatted (mass_mailing) - based on mail cancel state
|
|
* sms: set in _prepare_mass_sms_trace_values in composer if sms is
|
|
in cancel state; either blacklisted (sms) or in opt_out / seen list
|
|
(sms);
|
|
* void mail / void sms number -> error (mail_missing, sms_number_missing)
|
|
* invalid mail / invalid sms number -> error (RECIPIENT, sms_number_format)
|
|
* exception: set in _postprocess_sent_message (_postprocess_iap_sent_sms)
|
|
if mail (sms) not sent with failure type, reset if sent;
|
|
* sent: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if
|
|
mail (sms) sent
|
|
* clicked: triggered by add_click
|
|
* opened: triggered by add_click + blank gif (mail) + gateway reply (mail)
|
|
* replied: triggered by gateway reply (mail)
|
|
* bounced: triggered by gateway bounce (mail) or in _prepare_mass_sms_trace_values
|
|
if sms_number_format error when sending sms (sms)
|
|
"""
|
|
_name = 'mailing.trace'
|
|
_description = 'Mailing Statistics'
|
|
_rec_name = 'id'
|
|
_order = 'create_date DESC'
|
|
|
|
trace_type = fields.Selection([('mail', 'Email')], string='Type', default='mail', required=True)
|
|
display_name = fields.Char(compute='_compute_display_name')
|
|
# mail data
|
|
mail_mail_id = fields.Many2one('mail.mail', string='Mail', index=True)
|
|
mail_mail_id_int = fields.Integer(
|
|
string='Mail ID (tech)',
|
|
help='ID of the related mail_mail. This field is an integer field because '
|
|
'the related mail_mail can be deleted separately from its statistics. '
|
|
'However the ID is needed for several action and controllers.',
|
|
index=True,
|
|
)
|
|
email = fields.Char(string="Email", help="Normalized email address")
|
|
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 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)
|
|
# Status
|
|
sent_datetime = fields.Datetime('Sent On')
|
|
open_datetime = fields.Datetime('Opened On')
|
|
reply_datetime = fields.Datetime('Replied On')
|
|
trace_status = fields.Selection(selection=[
|
|
('outgoing', 'Outgoing'),
|
|
('sent', 'Sent'),
|
|
('open', 'Opened'),
|
|
('reply', 'Replied'),
|
|
('bounce', 'Bounced'),
|
|
('error', 'Exception'),
|
|
('cancel', 'Canceled')], string='Status', default='outgoing')
|
|
failure_type = fields.Selection(selection=[
|
|
# generic
|
|
("unknown", "Unknown error"),
|
|
# mail
|
|
("mail_email_invalid", "Invalid email address"),
|
|
("mail_email_missing", "Missing email address"),
|
|
("mail_smtp", "Connection failed (outgoing mail server problem)"),
|
|
# mass mode
|
|
("mail_bl", "Blacklisted Address"),
|
|
("mail_optout", "Opted Out"),
|
|
("mail_dup", "Duplicated Email"),
|
|
], string='Failure type')
|
|
# Link tracking
|
|
links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click')
|
|
links_click_datetime = fields.Datetime('Clicked On', help='Stores last click datetime in case of multi clicks.')
|
|
|
|
@api.depends('trace_type', 'mass_mailing_id')
|
|
def _compute_display_name(self):
|
|
for trace in self:
|
|
trace.display_name = '%s: %s (%s)' % (trace.trace_type, trace.mass_mailing_id.name, trace.id)
|
|
|
|
@api.model_create_multi
|
|
def create(self, values_list):
|
|
for values in values_list:
|
|
if 'mail_mail_id' in values:
|
|
values['mail_mail_id_int'] = values['mail_mail_id']
|
|
return super(MailingTrace, self).create(values_list)
|
|
|
|
def action_view_contact(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'view_mode': 'form',
|
|
'res_model': self.model,
|
|
'target': 'current',
|
|
'res_id': self.res_id
|
|
}
|
|
|
|
def set_sent(self, domain=None):
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.write({'trace_status': 'sent', 'sent_datetime': fields.Datetime.now(), 'failure_type': False})
|
|
return traces
|
|
|
|
def set_opened(self, domain=None):
|
|
""" Reply / Open are a bit shared in various processes: reply implies
|
|
open, click implies open. Let us avoid status override by skipping traces
|
|
that are not already opened or replied. """
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.filtered(lambda t: t.trace_status not in ('open', 'reply')).write({'trace_status': 'open', 'open_datetime': fields.Datetime.now()})
|
|
return traces
|
|
|
|
def set_clicked(self, domain=None):
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.write({'links_click_datetime': fields.Datetime.now()})
|
|
return traces
|
|
|
|
def set_replied(self, domain=None):
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.write({'trace_status': 'reply', 'reply_datetime': fields.Datetime.now()})
|
|
return traces
|
|
|
|
def set_bounced(self, domain=None):
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.write({'trace_status': 'bounce'})
|
|
return traces
|
|
|
|
def set_failed(self, domain=None, failure_type=False):
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.write({'trace_status': 'error', 'failure_type': failure_type})
|
|
return traces
|
|
|
|
def set_canceled(self, domain=None):
|
|
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
|
|
traces.write({'trace_status': 'cancel'})
|
|
return traces
|