mailing_trace.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from odoo import api, fields, models
  4. class MailingTrace(models.Model):
  5. """ MailingTrace models the statistics collected about emails. Those statistics
  6. are stored in a separated model and table to avoid bloating the mail_mail table
  7. with statistics values. This also allows to delete emails send with mass mailing
  8. without loosing the statistics about them.
  9. Note:: State management / Error codes / Failure types summary
  10. * trace_status
  11. 'outgoing', 'sent', 'opened', 'replied',
  12. 'error', 'bouce', 'cancel'
  13. * failure_type
  14. # generic
  15. 'unknown',
  16. # mass_mailing
  17. "mail_email_invalid", "mail_smtp", "mail_email_missing"
  18. # mass mailing mass mode specific codes
  19. "mail_bl", "mail_optout", "mail_dup"
  20. # mass_mailing_sms
  21. 'sms_number_missing', 'sms_number_format', 'sms_credit',
  22. 'sms_server', 'sms_acc'
  23. # mass_mailing_sms mass mode specific codes
  24. 'sms_blacklist', 'sms_duplicate', 'sms_optout',
  25. * cancel:
  26. * mail: set in get_mail_values in composer, if email is blacklisted
  27. (mail) or in opt_out / seen list (mass_mailing) or email_to is void
  28. or incorrectly formatted (mass_mailing) - based on mail cancel state
  29. * sms: set in _prepare_mass_sms_trace_values in composer if sms is
  30. in cancel state; either blacklisted (sms) or in opt_out / seen list
  31. (sms);
  32. * void mail / void sms number -> error (mail_missing, sms_number_missing)
  33. * invalid mail / invalid sms number -> error (RECIPIENT, sms_number_format)
  34. * exception: set in _postprocess_sent_message (_postprocess_iap_sent_sms)
  35. if mail (sms) not sent with failure type, reset if sent;
  36. * sent: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if
  37. mail (sms) sent
  38. * clicked: triggered by add_click
  39. * opened: triggered by add_click + blank gif (mail) + gateway reply (mail)
  40. * replied: triggered by gateway reply (mail)
  41. * bounced: triggered by gateway bounce (mail) or in _prepare_mass_sms_trace_values
  42. if sms_number_format error when sending sms (sms)
  43. """
  44. _name = 'mailing.trace'
  45. _description = 'Mailing Statistics'
  46. _rec_name = 'id'
  47. _order = 'create_date DESC'
  48. trace_type = fields.Selection([('mail', 'Email')], string='Type', default='mail', required=True)
  49. display_name = fields.Char(compute='_compute_display_name')
  50. # mail data
  51. mail_mail_id = fields.Many2one('mail.mail', string='Mail', index='btree_not_null')
  52. mail_mail_id_int = fields.Integer(
  53. string='Mail ID (tech)',
  54. help='ID of the related mail_mail. This field is an integer field because '
  55. 'the related mail_mail can be deleted separately from its statistics. '
  56. 'However the ID is needed for several action and controllers.',
  57. index='btree_not_null',
  58. )
  59. email = fields.Char(string="Email", help="Normalized email address")
  60. message_id = fields.Char(string='Message-ID') # email Message-ID (RFC 2392)
  61. medium_id = fields.Many2one(related='mass_mailing_id.medium_id')
  62. source_id = fields.Many2one(related='mass_mailing_id.source_id')
  63. # document
  64. model = fields.Char(string='Document model', required=True)
  65. res_id = fields.Many2oneReference(string='Document ID', model_field='model')
  66. # campaign data
  67. mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True, ondelete='cascade')
  68. campaign_id = fields.Many2one(
  69. related='mass_mailing_id.campaign_id',
  70. string='Campaign',
  71. store=True, readonly=True, index='btree_not_null')
  72. # Status
  73. sent_datetime = fields.Datetime('Sent On')
  74. open_datetime = fields.Datetime('Opened On')
  75. reply_datetime = fields.Datetime('Replied On')
  76. trace_status = fields.Selection(selection=[
  77. ('outgoing', 'Outgoing'),
  78. ('sent', 'Sent'),
  79. ('open', 'Opened'),
  80. ('reply', 'Replied'),
  81. ('bounce', 'Bounced'),
  82. ('error', 'Exception'),
  83. ('cancel', 'Canceled')], string='Status', default='outgoing')
  84. failure_type = fields.Selection(selection=[
  85. # generic
  86. ("unknown", "Unknown error"),
  87. # mail
  88. ("mail_email_invalid", "Invalid email address"),
  89. ("mail_email_missing", "Missing email address"),
  90. ("mail_smtp", "Connection failed (outgoing mail server problem)"),
  91. # mass mode
  92. ("mail_bl", "Blacklisted Address"),
  93. ("mail_optout", "Opted Out"),
  94. ("mail_dup", "Duplicated Email"),
  95. ], string='Failure type')
  96. # Link tracking
  97. links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click')
  98. links_click_datetime = fields.Datetime('Clicked On', help='Stores last click datetime in case of multi clicks.')
  99. _sql_constraints = [
  100. # Required on a Many2one reference field is not sufficient as actually
  101. # writing 0 is considered as a valid value, because this is an integer field.
  102. # We therefore need a specific constraint check.
  103. ('check_res_id_is_set',
  104. 'CHECK(res_id IS NOT NULL AND res_id !=0 )',
  105. 'Traces have to be linked to records with a not null res_id.')
  106. ]
  107. @api.depends('trace_type', 'mass_mailing_id')
  108. def _compute_display_name(self):
  109. for trace in self:
  110. trace.display_name = '%s: %s (%s)' % (trace.trace_type, trace.mass_mailing_id.name, trace.id)
  111. @api.model_create_multi
  112. def create(self, values_list):
  113. for values in values_list:
  114. if 'mail_mail_id' in values:
  115. values['mail_mail_id_int'] = values['mail_mail_id']
  116. return super(MailingTrace, self).create(values_list)
  117. def action_view_contact(self):
  118. self.ensure_one()
  119. return {
  120. 'type': 'ir.actions.act_window',
  121. 'view_mode': 'form',
  122. 'res_model': self.model,
  123. 'target': 'current',
  124. 'res_id': self.res_id
  125. }
  126. def set_sent(self, domain=None):
  127. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  128. traces.write({'trace_status': 'sent', 'sent_datetime': fields.Datetime.now(), 'failure_type': False})
  129. return traces
  130. def set_opened(self, domain=None):
  131. """ Reply / Open are a bit shared in various processes: reply implies
  132. open, click implies open. Let us avoid status override by skipping traces
  133. that are not already opened or replied. """
  134. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  135. traces.filtered(lambda t: t.trace_status not in ('open', 'reply')).write({'trace_status': 'open', 'open_datetime': fields.Datetime.now()})
  136. return traces
  137. def set_clicked(self, domain=None):
  138. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  139. traces.write({'links_click_datetime': fields.Datetime.now()})
  140. return traces
  141. def set_replied(self, domain=None):
  142. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  143. traces.write({'trace_status': 'reply', 'reply_datetime': fields.Datetime.now()})
  144. return traces
  145. def set_bounced(self, domain=None):
  146. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  147. traces.write({'trace_status': 'bounce'})
  148. return traces
  149. def set_failed(self, domain=None, failure_type=False):
  150. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  151. traces.write({'trace_status': 'error', 'failure_type': failure_type})
  152. return traces
  153. def set_canceled(self, domain=None):
  154. traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
  155. traces.write({'trace_status': 'cancel'})
  156. return traces