Files
odoo_source/addons/mass_mailing/models/mailing_trace.py
T
Florian Charlier bd5a68a1a7 [IMP] mail, mass_mailing, sms: process delivery reports
Also impacts mass_mailing_sms, test_mail_sms,
test_mass_mailing

This PR adds support to receive sms delivery reports.

Before this PR, an SMS was considered 'sent' when successfully
handled by the third party. The user couldn't know if/when an
SMS was actually sent for delivery or delivered to the
recipient's device.
This was similar to the behavior for emails as delivery reports
are not commonly used (and not supported in Odoo).

With this work, the SMS `pending` state is introduced in mail,
mass_mailing, sms and mass_mailing_sms contexts although only fully
used in the latter two modules (+tests of course).

Because of the huge cost related to upgrading very large existing
databases, the following compromises were made:

1. An email and sms notification/trace SENT means DELIVERED.
  Those that are sent but NOT DELIVERED are PENDING.
  The difference between email and sms traces reinforced with this PR
  is that an email sent will be counted as "sent" ~ "delivered"
  unless an error is returned for emails while for SMS it can only be
  reached if a delivery report is received.
2. The Link between an SMS uuid (shared with trusted parties) and
  the tracking records (notifications or traces) is done via an
  explicit relationship table (sms_tracker) instead of via a new field.
  This however allowed to nicely concentrate the state update logic.

A `process` state is added to represent an intermediate
step in the sending process, such as held at IAP for SMS.

A few adjustments are also included to update for IAP api v3.
Also, adapts and includes new tests.

Task-2560666

Part-of: odoo/odoo#133392
2023-10-24 12:38:13 +00:00

188 lines
8.5 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', 'process', 'pending', 'sent', 'opened', 'replied',
'error', 'bounce', 'cancel'
* failure_type
# generic
'unknown',
# mass_mailing
"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_country_not_supported', 'sms_registration_needed',
# mass_mailing_sms mass mode specific codes
'sms_blacklist', 'sms_duplicate', 'sms_optout',
* cancel:
* 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
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;
* 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)
* 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)
# mail data
mail_mail_id = fields.Many2one('mail.mail', string='Mail', index='btree_not_null')
mail_mail_id_int = fields.Integer(
string='Mail ID (tech)',
help='ID of the related mail_mail. This field is an integer field because '
'the related mail_mail can be deleted separately from its statistics. '
'However the ID is needed for several action and controllers.',
index='btree_not_null',
)
email = fields.Char(string="Email", help="Normalized email address")
message_id = fields.Char(string='Message-ID') # email Message-ID (RFC 2392)
medium_id = fields.Many2one(related='mass_mailing_id.medium_id')
source_id = fields.Many2one(related='mass_mailing_id.source_id')
# document
model = fields.Char(string='Document model', required=True)
res_id = fields.Many2oneReference(string='Document ID', model_field='model')
# campaign data
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True, ondelete='cascade')
campaign_id = fields.Many2one(
related='mass_mailing_id.campaign_id',
string='Campaign',
store=True, readonly=True, index='btree_not_null')
# Status
sent_datetime = fields.Datetime('Sent On')
open_datetime = fields.Datetime('Opened On')
reply_datetime = fields.Datetime('Replied On')
trace_status = fields.Selection(selection=[
('outgoing', 'Outgoing'),
('process', 'Processing'),
('pending', 'Sent'),
('sent', 'Delivered'),
('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_bounce", "Bounce"),
("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_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.')
]
@api.depends('trace_type', 'mass_mailing_id')
def _compute_display_name(self):
for trace in self:
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:
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, bounce_message=False):
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({
'failure_reason': bounce_message,
'failure_type': 'mail_bounce',
'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