[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
This commit is contained in:
@@ -27,7 +27,9 @@ class MailNotification(models.Model):
|
||||
], string='Notification Type', default='inbox', index=True, required=True)
|
||||
notification_status = fields.Selection([
|
||||
('ready', 'Ready to Send'),
|
||||
('sent', 'Sent'),
|
||||
('process', 'Processing'), # being checked by intermediary like IAP for sms
|
||||
('pending', 'Sent'), # used with SMS; mail does not differentiate sent from delivered
|
||||
('sent', 'Delivered'),
|
||||
('bounce', 'Bounced'),
|
||||
('exception', 'Exception'),
|
||||
('canceled', 'Canceled')
|
||||
|
||||
@@ -68,6 +68,10 @@ export class Notification extends Record {
|
||||
|
||||
get statusIcon() {
|
||||
switch (this.notification_status) {
|
||||
case "process":
|
||||
return "fa fa-hourglass-half";
|
||||
case "pending":
|
||||
return "fa fa-paper-plane-o";
|
||||
case "sent":
|
||||
return "fa fa-check";
|
||||
case "bounce":
|
||||
@@ -84,8 +88,12 @@ export class Notification extends Record {
|
||||
|
||||
get statusTitle() {
|
||||
switch (this.notification_status) {
|
||||
case "sent":
|
||||
case "process":
|
||||
return _t("Processing");
|
||||
case "pending":
|
||||
return _t("Sent");
|
||||
case "sent":
|
||||
return _t("Delivered");
|
||||
case "bounce":
|
||||
return _t("Bounced");
|
||||
case "exception":
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Notification" duplicate="0">
|
||||
<header>
|
||||
<field name="notification_status" widget="statusbar"/>
|
||||
<field name="notification_status" widget="statusbar" statusbar_visible="ready,sent"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
|
||||
@@ -103,11 +103,8 @@ class MailMail(models.Model):
|
||||
return email_list
|
||||
|
||||
def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None):
|
||||
mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed
|
||||
for mail in self:
|
||||
if mail.mailing_id:
|
||||
if mail_sent is True and mail.mailing_trace_ids:
|
||||
mail.mailing_trace_ids.set_sent()
|
||||
elif mail_sent is False and mail.mailing_trace_ids:
|
||||
mail.mailing_trace_ids.set_failed(failure_type=failure_type)
|
||||
if failure_type: # we consider that a recipient error is a failure with mass mailing and show them as failed
|
||||
self.filtered('mailing_id').mailing_trace_ids.set_failed(failure_type=failure_type)
|
||||
else:
|
||||
self.filtered('mailing_id').mailing_trace_ids.set_sent()
|
||||
return super()._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type)
|
||||
|
||||
@@ -212,6 +212,8 @@ class MassMailing(models.Model):
|
||||
expected = fields.Integer(compute="_compute_statistics")
|
||||
canceled = fields.Integer(compute="_compute_statistics")
|
||||
sent = fields.Integer(compute="_compute_statistics")
|
||||
process = fields.Integer(compute="_compute_statistics")
|
||||
pending = fields.Integer(compute="_compute_statistics") # used with SMS when not yet 'delivered'
|
||||
delivered = fields.Integer(compute="_compute_statistics")
|
||||
opened = fields.Integer(compute="_compute_statistics")
|
||||
clicked = fields.Integer(compute="_compute_statistics")
|
||||
@@ -297,8 +299,8 @@ class MassMailing(models.Model):
|
||||
def _compute_statistics(self):
|
||||
""" Compute statistics of the mass mailing """
|
||||
for key in (
|
||||
'scheduled', 'expected', 'canceled', 'sent', 'delivered', 'opened',
|
||||
'clicked', 'replied', 'bounced', 'failed', 'received_ratio',
|
||||
'scheduled', 'expected', 'canceled', 'sent', 'pending', 'delivered', 'opened',
|
||||
'process', 'clicked', 'replied', 'bounced', 'failed', 'received_ratio',
|
||||
'opened_ratio', 'replied_ratio', 'bounced_ratio',
|
||||
):
|
||||
self[key] = False
|
||||
@@ -314,6 +316,8 @@ class MassMailing(models.Model):
|
||||
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 = 'process') AS process,
|
||||
COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'pending') AS pending,
|
||||
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,
|
||||
@@ -671,6 +675,9 @@ class MassMailing(models.Model):
|
||||
def action_view_traces_failed(self):
|
||||
return self._action_view_traces_filtered('failed')
|
||||
|
||||
def action_view_traces_process(self):
|
||||
return self._action_view_traces_filtered('process')
|
||||
|
||||
def action_view_traces_sent(self):
|
||||
return self._action_view_traces_filtered('sent')
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ class MailingTrace(models.Model):
|
||||
Note:: State management / Error codes / Failure types summary
|
||||
|
||||
* trace_status
|
||||
'outgoing', 'sent', 'opened', 'replied',
|
||||
'error', 'bouce', 'cancel'
|
||||
'outgoing', 'process', 'pending', 'sent', 'opened', 'replied',
|
||||
'error', 'bounce', 'cancel'
|
||||
* failure_type
|
||||
# generic
|
||||
'unknown',
|
||||
@@ -24,8 +24,8 @@ class MailingTrace(models.Model):
|
||||
# mass mailing mass mode specific codes
|
||||
"mail_bl", "mail_optout", "mail_dup"
|
||||
# mass_mailing_sms
|
||||
'sms_number_missing', 'sms_number_format', 'sms_credit',
|
||||
'sms_server', 'sms_acc'
|
||||
'sms_number_missing', 'sms_number_format', 'sms_credit', 'sms_server',
|
||||
'sms_acc', 'sms_country_not_supported', 'sms_registration_needed',
|
||||
# mass_mailing_sms mass mode specific codes
|
||||
'sms_blacklist', 'sms_duplicate', 'sms_optout',
|
||||
* cancel:
|
||||
@@ -39,8 +39,12 @@ class MailingTrace(models.Model):
|
||||
* invalid mail / invalid sms number -> error (RECIPIENT, sms_number_format)
|
||||
* exception: set in _postprocess_sent_message (_postprocess_iap_sent_sms)
|
||||
if mail (sms) not sent with failure type, reset if sent;
|
||||
* sent: set in _postprocess_sent_message (_postprocess_iap_sent_sms) if
|
||||
mail (sms) sent
|
||||
* process: (used in sms): set in SmsTracker._update_sms_traces when held back
|
||||
(at IAP) before actual sending to the sms_service.
|
||||
* pending: (used in sms): default value for sent sms.
|
||||
* sent: set in
|
||||
* _postprocess_sent_message if mail
|
||||
* SmsTracker._update_sms_traces if sms, when delivery report is received.
|
||||
* clicked: triggered by add_click
|
||||
* opened: triggered by add_click + blank gif (mail) + gateway reply (mail)
|
||||
* replied: triggered by gateway reply (mail)
|
||||
@@ -81,7 +85,9 @@ class MailingTrace(models.Model):
|
||||
reply_datetime = fields.Datetime('Replied On')
|
||||
trace_status = fields.Selection(selection=[
|
||||
('outgoing', 'Outgoing'),
|
||||
('sent', 'Sent'),
|
||||
('process', 'Processing'),
|
||||
('pending', 'Sent'),
|
||||
('sent', 'Delivered'),
|
||||
('open', 'Opened'),
|
||||
('reply', 'Replied'),
|
||||
('bounce', 'Bounced'),
|
||||
|
||||
@@ -20,6 +20,8 @@ class MailingTraceReport(models.Model):
|
||||
email_from = fields.Char('From', readonly=True)
|
||||
# traces
|
||||
scheduled = fields.Integer(readonly=True)
|
||||
processing = fields.Integer(readonly=True)
|
||||
pending = fields.Integer(readonly=True) # Used with SMS before a delivery report is received
|
||||
sent = fields.Integer(readonly=True)
|
||||
delivered = fields.Integer(readonly=True)
|
||||
error = fields.Integer(readonly=True)
|
||||
@@ -59,8 +61,10 @@ class MailingTraceReport(models.Model):
|
||||
'mailing.state',
|
||||
'mailing.email_from',
|
||||
"COUNT(trace.id) as scheduled",
|
||||
'COUNT(trace.sent_datetime) as sent',
|
||||
"(COUNT(trace.id) - COUNT(trace.trace_status) FILTER (WHERE trace.trace_status IN ('error', 'bounce', 'cancel'))) as delivered",
|
||||
"COUNT(trace.sent_datetime) as sent",
|
||||
"(COUNT(trace.id) - COUNT(trace.trace_status) FILTER (WHERE trace.trace_status IN ('outgoing', 'pending', 'process', 'error', 'bounce', 'cancel'))) as delivered",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'process') as processing",
|
||||
"COUNT(trace.trace_status) FILTER (WHERE trace.trace_status = 'pending') as pending",
|
||||
"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 canceled",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<field name="state"/>
|
||||
<field name="scheduled"/>
|
||||
<field name="sent"/>
|
||||
<field name="processing" optional="hide"/>
|
||||
<field name="pending" optional="hide"/>
|
||||
<field name="delivered"/>
|
||||
<field name="opened"/>
|
||||
<field name="replied"/>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<button name="action_cancel" type="object" invisible="state != 'in_queue'" class="btn-secondary" string="Cancel" data-hotkey="x"/>
|
||||
<button name="action_retry_failed" type="object" invisible="state != 'done' or failed == 0" class="oe_highlight" string="Retry" data-hotkey="y"/>
|
||||
|
||||
<field name="state" readonly="1" widget="statusbar"/>
|
||||
<field name="state" readonly="1" widget="statusbar" statusbar_visible="draft,in_queue,sending,done"/>
|
||||
</header>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="state not in ['in_queue', 'done'] and sent == 0 and canceled == 0 and scheduled == 0 and failed == 0 and not warning_message">
|
||||
@@ -96,6 +96,16 @@
|
||||
</strong>
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_mails_process" invisible="process == 0">
|
||||
<button class="btn btn-link py-0"
|
||||
name="action_view_traces_process"
|
||||
type="object">
|
||||
<strong>
|
||||
<field name="process" class="oe_inline me-2"/>
|
||||
<span name="process_text">emails are being processed.</span>
|
||||
</strong>
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_mails_sent" invisible="sent == 0 and state in ('draft', 'test', 'in_queue')">
|
||||
<button class="btn btn-link py-0"
|
||||
name="action_view_traces_sent"
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
<field name="mass_mailing_id"/>
|
||||
<filter string="Scheduled" name="filter_scheduled" domain="[('trace_status', '=', 'outgoing')]"/>
|
||||
<filter string="Canceled" name="filter_canceled" domain="[('trace_status', '=', 'cancel')]"/>
|
||||
<filter string="Processing" name="filter_process" domain="[('trace_status', '=', 'process')]"/>
|
||||
<filter string="Sent" name="filter_sent" domain="[('sent_datetime', '!=', False)]"/>
|
||||
<filter string="Clicked" name="filter_clicked" domain="[('links_click_datetime', '!=', False)]"/>
|
||||
<filter string="Delivered" name="filter_delivered" domain="[('sent_datetime', '!=', False), ('trace_status', 'not in', ['error', 'cancel'])]"/>
|
||||
<filter string="Delivered" name="filter_delivered" domain="[('sent_datetime', '!=', False), ('trace_status', 'not in', ['error', 'cancel', 'process', 'pending'])]"/>
|
||||
<filter string="Opened" name="filter_opened" domain="[('trace_status', 'in', ['open', 'reply'])]"/>
|
||||
<filter string="Replied" name="filter_replied" domain="[('trace_status', '=', 'reply')]"/>
|
||||
<filter string="Bounced" name="filter_bounced" domain="[('trace_status', '=', 'bounce')]"/>
|
||||
@@ -76,6 +77,9 @@
|
||||
<field name="model">mailing.trace</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Mail Statistics" create="0" edit="0">
|
||||
<header>
|
||||
<field name="trace_status" widget="statusbar" statusbar_visible="outgoing,sent,error"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_contact"
|
||||
@@ -87,7 +91,6 @@
|
||||
</div>
|
||||
<group>
|
||||
<group string="Status">
|
||||
<field name="trace_status"/>
|
||||
<field name="failure_type" invisible="not failure_type"/>
|
||||
<field name="sent_datetime" invisible="not sent_datetime"/>
|
||||
<field name="links_click_datetime" invisible="not links_click_datetime"/>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<field name="sms_number">+32465000003</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_24"/>
|
||||
<field name="trace_status">sent</field>
|
||||
<field name="trace_status">pending</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>
|
||||
@@ -48,7 +48,7 @@
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_33"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_server</field>
|
||||
<field name="failure_type">sms_not_allowed</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>
|
||||
@@ -58,7 +58,8 @@
|
||||
<field name="sms_number">123123</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_33"/>
|
||||
<field name="trace_status">bounce</field>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_not_delivered</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>
|
||||
@@ -68,8 +69,8 @@
|
||||
<field name="sms_number"></field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="res_id" ref="base.res_partner_address_34"/>
|
||||
<field name="trace_status">error</field>
|
||||
<field name="failure_type">sms_number_missing</field>
|
||||
<field name="trace_status">bounce</field>
|
||||
<field name="failure_type">sms_invalid_destination</field>
|
||||
<field name="sent_datetime" eval="False"/>
|
||||
</record>
|
||||
|
||||
@@ -123,7 +124,7 @@
|
||||
<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="trace_status">sent</field>
|
||||
<field name="trace_status">pending</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">
|
||||
@@ -132,7 +133,7 @@
|
||||
<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="trace_status">sent</field>
|
||||
<field name="trace_status">pending</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">
|
||||
|
||||
@@ -7,4 +7,5 @@ from . import mailing_mailing
|
||||
from . import mailing_trace
|
||||
from . import res_users
|
||||
from . import sms_sms
|
||||
from . import sms_tracker
|
||||
from . import utm
|
||||
|
||||
@@ -247,12 +247,6 @@ class Mailing(models.Model):
|
||||
if res_ids:
|
||||
composer = self.env['sms.composer'].with_context(active_id=False).create(mailing._send_sms_get_composer_values(res_ids))
|
||||
composer._action_send_sms()
|
||||
|
||||
mailing.write({
|
||||
'state': 'done',
|
||||
'sent_date': fields.Datetime.now(),
|
||||
'kpi_mail_required': not mailing.sent_date,
|
||||
})
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,6 @@ import random
|
||||
import string
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class MailingTrace(models.Model):
|
||||
@@ -22,20 +21,29 @@ class MailingTrace(models.Model):
|
||||
string='SMS ID (tech)',
|
||||
index='btree_not_null'
|
||||
# Integer because the related sms.sms can be deleted separately from its statistics.
|
||||
# However the ID is needed for several action and controllers.
|
||||
# However, the ID is needed for several action and controllers.
|
||||
)
|
||||
sms_tracker_ids = fields.One2many('sms.tracker', 'mailing_trace_id', string='SMS Trackers')
|
||||
sms_number = fields.Char('Number')
|
||||
sms_code = fields.Char('Code')
|
||||
failure_type = fields.Selection(selection_add=[
|
||||
('sms_number_missing', 'Missing Number'),
|
||||
('sms_number_format', 'Wrong Number Format'),
|
||||
('sms_credit', 'Insufficient Credit'),
|
||||
('sms_country_not_supported', 'Country Not Supported'),
|
||||
('sms_registration_needed', 'Country-specific Registration Required'),
|
||||
('sms_server', 'Server Error'),
|
||||
('sms_acc', 'Unregistered Account'),
|
||||
# mass mode specific codes
|
||||
('sms_blacklist', 'Blacklisted'),
|
||||
('sms_duplicate', 'Duplicate'),
|
||||
('sms_optout', 'Opted Out'),
|
||||
# delivery report errors
|
||||
('sms_expired', 'Expired'),
|
||||
('sms_invalid_destination', 'Invalid Destination'),
|
||||
('sms_not_allowed', 'Not Allowed'),
|
||||
('sms_not_delivered', 'Not Delivered'),
|
||||
('sms_rejected', 'Rejected'),
|
||||
])
|
||||
|
||||
@api.model_create_multi
|
||||
|
||||
@@ -27,20 +27,3 @@ class SmsSms(models.Model):
|
||||
body = re.sub(re.escape(url) + r'(?![\w@:%.+&~#=/-])', url + f'/s/{sms.id}', body)
|
||||
res[sms.id] = body
|
||||
return res
|
||||
|
||||
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, unlink_failed=False, unlink_sent=True):
|
||||
all_sms_ids = [item['res_id'] for item in iap_results]
|
||||
if any(sms.mailing_id for sms in self.env['sms.sms'].sudo().browse(all_sms_ids)):
|
||||
for state in self.IAP_TO_SMS_STATE.keys():
|
||||
sms_ids = [item['res_id'] for item in iap_results if item['state'] == state]
|
||||
traces = self.env['mailing.trace'].sudo().search([
|
||||
('sms_sms_id_int', 'in', sms_ids)
|
||||
])
|
||||
if traces and state == 'success':
|
||||
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,
|
||||
unlink_failed=unlink_failed, unlink_sent=unlink_sent
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SmsTracker(models.Model):
|
||||
_inherit = "sms.tracker"
|
||||
|
||||
SMS_STATE_TO_TRACE_STATUS = {
|
||||
'error': 'error',
|
||||
'process': 'process',
|
||||
'outgoing': 'outgoing',
|
||||
'canceled': 'cancel',
|
||||
'pending': 'pending',
|
||||
'sent': 'sent',
|
||||
}
|
||||
|
||||
mailing_trace_id = fields.Many2one('mailing.trace', ondelete='cascade')
|
||||
|
||||
def _action_update_from_provider_error(self, provider_error):
|
||||
error_status, failure_type, failure_reason = super()._action_update_from_provider_error(provider_error)
|
||||
self._update_sms_traces(error_status or 'error', failure_type=failure_type, failure_reason=failure_reason)
|
||||
return error_status, failure_type, failure_reason
|
||||
|
||||
def _action_update_from_sms_state(self, sms_state, failure_type=False, failure_reason=False):
|
||||
super()._action_update_from_sms_state(sms_state, failure_type=failure_type, failure_reason=failure_reason)
|
||||
trace_status = self.SMS_STATE_TO_TRACE_STATUS[sms_state]
|
||||
traces = self._update_sms_traces(trace_status, failure_type=failure_type, failure_reason=failure_reason)
|
||||
self._update_sms_mailings(trace_status, traces)
|
||||
|
||||
def _update_sms_traces(self, trace_status, failure_type=False, failure_reason=False):
|
||||
if not self.mailing_trace_id: # avoid a search below
|
||||
return self.env['mailing.trace']
|
||||
# See _update_sms_notifications
|
||||
statuses_to_ignore = {
|
||||
'cancel': ['cancel', 'process', 'pending', 'sent'],
|
||||
'outgoing': ['outgoing', 'process', 'pending', 'sent'],
|
||||
'process': ['process', 'pending', 'sent'],
|
||||
'pending': ['pending', 'sent'],
|
||||
'bounce': ['bounce'],
|
||||
'sent': ['sent'],
|
||||
'error': ['error'],
|
||||
}[trace_status]
|
||||
traces = self.mailing_trace_id.filtered(lambda t: t.trace_status not in statuses_to_ignore)
|
||||
if traces:
|
||||
traces_values = {
|
||||
'trace_status': trace_status,
|
||||
'failure_type': failure_type,
|
||||
'failure_reason': failure_reason,
|
||||
**{'sent_datetime': fields.Datetime.now() if trace_status == 'pending' else {}},
|
||||
}
|
||||
traces.write(traces_values)
|
||||
return traces
|
||||
|
||||
def _update_sms_mailings(self, trace_status, traces):
|
||||
traces.flush_recordset(['trace_status'])
|
||||
|
||||
if trace_status == 'process':
|
||||
traces.mass_mailing_id.write({'state': 'sending'})
|
||||
return
|
||||
|
||||
mailings_to_mark_done = self.env['mailing.mailing'].search([
|
||||
('id', 'in', traces.mass_mailing_id.ids),
|
||||
'!', ('mailing_trace_ids.trace_status', '=', 'process'), # = not any trace with 'process' status
|
||||
('state', '!=', 'done'),
|
||||
])
|
||||
|
||||
if mailings_to_mark_done:
|
||||
if self.env.user.is_public: # From webhook event
|
||||
mailings_to_mark_done._track_set_author(self.env.ref('base.partner_root'))
|
||||
for mailing in mailings_to_mark_done:
|
||||
mailing.write({
|
||||
'state': 'done',
|
||||
'sent_date': fields.Datetime.now(),
|
||||
'kpi_mail_required': not mailing.sent_date
|
||||
})
|
||||
@@ -34,7 +34,8 @@ class MassSMSCase(SMSCase, MockLinkTracker):
|
||||
# TRACE
|
||||
'partner': res.partner record (may be empty),
|
||||
'number': number used for notification (may be empty, computed based on partner),
|
||||
'trace_status': outgoing / sent / cancel / bounce / error / opened (sent by default),
|
||||
'trace_status': outgoing / process / pending / sent / cancel / bounce / error / opened
|
||||
(sent by default),
|
||||
'record: linked record,
|
||||
# SMS.SMS
|
||||
'content': optional: if set, check content of sent SMS;
|
||||
@@ -61,6 +62,8 @@ class MassSMSCase(SMSCase, MockLinkTracker):
|
||||
'error': 'error',
|
||||
'cancel': 'canceled',
|
||||
'bounce': 'error',
|
||||
'process': 'process',
|
||||
'pending': 'pending',
|
||||
}
|
||||
traces = self.env['mailing.trace'].search([
|
||||
('mass_mailing_id', 'in', mailing.ids),
|
||||
@@ -90,11 +93,11 @@ class MassSMSCase(SMSCase, MockLinkTracker):
|
||||
self.assertTrue(bool(trace.sms_sms_id_int))
|
||||
|
||||
if check_sms:
|
||||
if status == 'sent':
|
||||
if status in {'process', 'pending', 'sent'}:
|
||||
if sent_unlink:
|
||||
self.assertSMSIapSent([number], content=content)
|
||||
else:
|
||||
self.assertSMS(partner, number, 'sent', content=content)
|
||||
self.assertSMS(partner, number, status, content=content)
|
||||
elif status in state_mapping:
|
||||
sms_state = state_mapping[status]
|
||||
failure_type = recipient_info['failure_type'] if status in ('error', 'cancel', 'bounce') else None
|
||||
@@ -123,6 +126,7 @@ class MassSMSCase(SMSCase, MockLinkTracker):
|
||||
(url, is_shortened),
|
||||
link_params=link_params,
|
||||
)
|
||||
return traces
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# GATEWAY TOOLS
|
||||
@@ -137,6 +141,13 @@ class MassSMSCase(SMSCase, MockLinkTracker):
|
||||
self.assertTrue(bool(sms_sent))
|
||||
return self.gateway_sms_sent_click(sms_sent)
|
||||
|
||||
def gateway_sms_delivered(self, mailing, record):
|
||||
""" Simulate a delivery report received for a sent SMS."""
|
||||
trace = mailing.mailing_trace_ids.filtered(lambda t: t.model == record._name and t.res_id == record.id)
|
||||
sms_sent = self._find_sms_sent(self.env['res.partner'], trace.sms_number)
|
||||
self.assertTrue(bool(sms_sent))
|
||||
trace.trace_status = 'sent'
|
||||
|
||||
def gateway_sms_sent_click(self, sms_sent):
|
||||
""" When clicking on a link in a SMS we actually don't have any
|
||||
easy information in body, only body. We currently click on all found
|
||||
|
||||
@@ -20,18 +20,18 @@
|
||||
<field name="arch" type="xml">
|
||||
<!-- Buttons / Actions -->
|
||||
<xpath expr="//button[@name='action_launch']" position="attributes">
|
||||
<attribute name="invisible">mailing_type != 'mail' or state in ('in_queue', 'sending', 'done')</attribute>
|
||||
<attribute name="invisible">mailing_type != 'mail' or state in ('in_queue', 'sending', 'processing', 'done')</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='action_schedule']" position="before">
|
||||
<field name="sms_force_send" invisible="1"/>
|
||||
<field name="schedule_type" invisible="1" readonly="state not in ['draft', 'in_queue']"/>
|
||||
<button name="action_put_in_queue" type="object"
|
||||
string="Send" class="oe_highlight" data-hotkey="v"
|
||||
invisible="mailing_type == 'mail' or state in ('in_queue', 'sending', 'done') or sms_force_send"
|
||||
invisible="mailing_type == 'mail' or state in ('in_queue', 'sending', 'processing', 'done') or sms_force_send"
|
||||
confirm="This will send SMS to all recipients. Do you still want to proceed?"/>
|
||||
<button name="action_send_mail" type="object"
|
||||
string="Send Now" class="oe_highlight" data-hotkey="g"
|
||||
invisible="mailing_type == 'mail' or state in ('in_queue', 'done') or schedule_type == 'scheduled' or not sms_force_send"
|
||||
invisible="mailing_type == 'mail' or state in ('in_queue', 'processing', 'done') or schedule_type == 'scheduled' or not sms_force_send"
|
||||
confirm="This will send SMS to all recipients now. Do you still want to proceed?"/>
|
||||
</xpath>
|
||||
<!-- Headers / Warnings -->
|
||||
@@ -67,19 +67,25 @@
|
||||
<attribute name="invisible">mailing_type != 'mail'</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='scheduled_text']" position="after">
|
||||
<span name="scheduled_text_sms" invisible="mailing_type != 'sms'">SMS Text Message are in queue and will be sent soon.</span>
|
||||
<span name="scheduled_text_sms" invisible="mailing_type != 'sms'">SMS Text Messages are in queue and will be sent soon.</span>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='process_text']" position="attributes">
|
||||
<attribute name="invisible">mailing_type != 'mail'</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='process_text']" position="after">
|
||||
<span name="process_text_sms" invisible="mailing_type != 'sms'">SMS Text Messages are being processed.</span>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='sent']" position="attributes">
|
||||
<attribute name="invisible">mailing_type != 'mail'</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='sent']" position="after">
|
||||
<span name="sent_sms" invisible="mailing_type != 'sms'">SMS Text Message have been sent.</span>
|
||||
<span name="sent_sms" invisible="mailing_type != 'sms'">SMS Text Messages have been sent.</span>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='failed_text']" position="attributes">
|
||||
<attribute name="invisible">mailing_type != 'mail'</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='failed_text']" position="after">
|
||||
<span name="failed_text_sms" invisible="mailing_type != 'sms'">SMS Text Message could not be sent.</span>
|
||||
<span name="failed_text_sms" invisible="mailing_type != 'sms'">SMS Text Messages could not be delivered.</span>
|
||||
</xpath>
|
||||
<xpath expr="//span[@name='next_departure_text']" position="attributes">
|
||||
<attribute name="invisible">mailing_type != 'mail'</attribute>
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
<field name="model">mailing.trace</field>
|
||||
<field name="inherit_id" ref="mass_mailing.mailing_trace_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='trace_status']" position="attributes">
|
||||
<attribute name="statusbar_visible">outgoing,pending,sent,error</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='email']" position="attributes">
|
||||
<attribute name="invisible">trace_type != 'mail'</attribute>
|
||||
</xpath>
|
||||
@@ -87,9 +90,18 @@
|
||||
<span widget="statinfo">Open Recipient</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info text-center" invisible="trace_status != 'error'" role="alert">
|
||||
<div class="alert alert-info text-center"
|
||||
invisible="trace_status != 'error' or
|
||||
failure_type in ('sms_not_delivered', 'sms_not_allowed', 'sms_invalid_destination', 'sms_rejected', 'sms_rejected')"
|
||||
role="alert">
|
||||
<strong>This SMS could not be sent.</strong>
|
||||
</div>
|
||||
<div class="alert alert-info text-center"
|
||||
invisible="trace_status != 'error' or
|
||||
failure_type not in ('sms_not_delivered', 'sms_not_allowed', 'sms_invalid_destination', 'sms_rejected', 'sms_rejected')"
|
||||
role="alert">
|
||||
<strong>This SMS could not be delivered.</strong>
|
||||
</div>
|
||||
<div class="alert alert-info text-center" invisible="trace_status != 'bounce'" role="alert">
|
||||
<strong>This number appears to be invalid.</strong>
|
||||
</div>
|
||||
@@ -113,7 +125,7 @@
|
||||
<field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/>
|
||||
<field name="medium_id"/>
|
||||
<field name="source_id"/>
|
||||
<field name="sms_sms_id_int" groups="base.group_no_one"/>
|
||||
<field name="sms_sms_id" groups="base.group_no_one"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.addons.phone_validation.tools import phone_validation
|
||||
|
||||
|
||||
class MassSMSTest(models.TransientModel):
|
||||
@@ -30,13 +30,12 @@ class MassSMSTest(models.TransientModel):
|
||||
# Returns a proper error if there is a syntax error with qweb
|
||||
body = self.env['mail.render.mixin']._render_template(body, self.mailing_id.mailing_model_real, record.ids)[record.id]
|
||||
|
||||
# res_id is used to map the result to the number to log notifications as IAP does not return numbers...
|
||||
# TODO: clean IAP to make it return a clean dict with numbers / use custom keys / rename res_id to external_id
|
||||
new_sms_messages_sudo = self.env['sms.sms'].sudo().create([
|
||||
{'body': body, 'number': number} for number in sanitized_numbers])
|
||||
sent_sms_list = self.env['sms.api']._send_sms_batch([{
|
||||
'res_id': number,
|
||||
'number': number,
|
||||
'content': body,
|
||||
} for number in sanitized_numbers])
|
||||
'numbers': [{'number': sms_id.number, 'uuid': sms_id.uuid} for sms_id in new_sms_messages_sudo],
|
||||
}], dlr=True)
|
||||
|
||||
error_messages = {}
|
||||
if any(sent_sms.get('state') != 'success' for sent_sms in sent_sms_list):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import werkzeug.urls
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo import _, Command, fields, models
|
||||
|
||||
|
||||
class SMSComposer(models.TransientModel):
|
||||
@@ -27,12 +27,13 @@ class SMSComposer(models.TransientModel):
|
||||
def _prepare_mass_sms_trace_values(self, record, sms_values):
|
||||
trace_code = self.env['mailing.trace']._get_random_code()
|
||||
trace_values = {
|
||||
'mass_mailing_id': self.mailing_id.id,
|
||||
'model': self.res_model,
|
||||
'res_id': record.id,
|
||||
'trace_type': 'sms',
|
||||
'mass_mailing_id': self.mailing_id.id,
|
||||
'sms_number': sms_values['number'],
|
||||
'sms_code': trace_code,
|
||||
'sms_number': sms_values['number'],
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': sms_values['uuid']})],
|
||||
'trace_type': 'sms',
|
||||
}
|
||||
if sms_values['state'] == 'error':
|
||||
trace_values['failure_type'] = sms_values['failure_type']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
{
|
||||
'name': 'SMS gateway',
|
||||
'version': '2.4',
|
||||
'version': '3.0',
|
||||
'category': 'Hidden/Tools',
|
||||
'summary': 'SMS Text Messaging',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
@@ -0,0 +1,48 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import Controller, request, route
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmsController(Controller):
|
||||
|
||||
@route('/sms/status', type='json', auth='public')
|
||||
def update_sms_status(self, message_statuses):
|
||||
"""Receive a batch of delivery reports from IAP
|
||||
|
||||
:param message_statuses:
|
||||
[
|
||||
{
|
||||
'sms_status': status0,
|
||||
'uuids': [uuid00, uuid01, ...],
|
||||
}, {
|
||||
'sms_status': status1,
|
||||
'uuids': [uuid10, uuid11, ...],
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
all_uuids = []
|
||||
for uuids, iap_status in ((status['uuids'], status['sms_status']) for status in message_statuses):
|
||||
self._check_status_values(uuids, iap_status, message_statuses)
|
||||
if sms_trackers_sudo := request.env['sms.tracker'].sudo().search([('sms_uuid', 'in', uuids)]):
|
||||
if state := request.env['sms.sms'].IAP_TO_SMS_STATE_SUCCESS.get(iap_status):
|
||||
sms_trackers_sudo._action_update_from_sms_state(state)
|
||||
else:
|
||||
sms_trackers_sudo._action_update_from_provider_error(iap_status)
|
||||
all_uuids += uuids
|
||||
http.request.env['sms.sms'].sudo().search([('uuid', 'in', all_uuids)]).unlink()
|
||||
return 'OK'
|
||||
|
||||
@staticmethod
|
||||
def _check_status_values(uuids, iap_status, message_statuses):
|
||||
"""Basic checks to avoid unnecessary queries and allow debugging."""
|
||||
if (not uuids or not iap_status or not re.match(r'^\w+$', iap_status)
|
||||
or any(not re.match(r'^[0-9a-f]{32}$', uuid) for uuid in uuids)):
|
||||
_logger.warning('Received ill-formatted SMS delivery report event: \n%s', message_statuses)
|
||||
raise UserError("Bad parameters")
|
||||
@@ -11,3 +11,4 @@ from . import res_partner
|
||||
from . import sms_api
|
||||
from . import sms_sms
|
||||
from . import sms_template
|
||||
from . import sms_tracker
|
||||
|
||||
@@ -11,11 +11,20 @@ class MailNotification(models.Model):
|
||||
('sms', 'SMS')
|
||||
], ondelete={'sms': 'cascade'})
|
||||
sms_id = fields.Many2one('sms.sms', string='SMS', index='btree_not_null', ondelete='set null')
|
||||
sms_tracker_ids = fields.One2many('sms.tracker', 'mail_notification_id', string="SMS Trackers")
|
||||
sms_number = fields.Char('SMS Number')
|
||||
failure_type = fields.Selection(selection_add=[
|
||||
('sms_number_missing', 'Missing Number'),
|
||||
('sms_number_format', 'Wrong Number Format'),
|
||||
('sms_credit', 'Insufficient Credit'),
|
||||
('sms_country_not_supported', 'Country Not Supported'),
|
||||
('sms_registration_needed', 'Country-specific Registration Required'),
|
||||
('sms_server', 'Server Error'),
|
||||
('sms_acc', 'Unregistered Account')
|
||||
('sms_acc', 'Unregistered Account'),
|
||||
# delivery report errors
|
||||
('sms_expired', 'Expired'),
|
||||
('sms_invalid_destination', 'Invalid Destination'),
|
||||
('sms_not_allowed', 'Not Allowed'),
|
||||
('sms_not_delivered', 'Not Delivered'),
|
||||
('sms_rejected', 'Rejected'),
|
||||
])
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo.addons.phone_validation.tools import phone_validation
|
||||
from odoo import api, Command, models, fields
|
||||
from odoo.tools import html2plaintext, plaintext2html
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -307,6 +306,7 @@ class MailThread(models.AbstractModel):
|
||||
'sms_number': sms.number,
|
||||
'notification_type': 'sms',
|
||||
'sms_id': sms.id,
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': sms.uuid})] if sms.state == 'outgoing' else False,
|
||||
'is_read': True, # discard Inbox notification
|
||||
'notification_status': 'ready' if sms.state == 'outgoing' else 'exception',
|
||||
'failure_type': '' if sms.state == 'outgoing' else sms.failure_type,
|
||||
@@ -324,6 +324,7 @@ class MailThread(models.AbstractModel):
|
||||
'notification_type': 'sms',
|
||||
'notification_status': 'ready',
|
||||
'sms_id': sms.id,
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': sms.uuid})],
|
||||
'sms_number': sms.number,
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from odoo import _, api, models
|
||||
from odoo.addons.iap.tools import iap_tools
|
||||
|
||||
DEFAULT_ENDPOINT = 'https://iap-sms.odoo.com'
|
||||
DEFAULT_ENDPOINT = 'https://sms.api.odoo.com'
|
||||
|
||||
|
||||
class SmsApi(models.AbstractModel):
|
||||
@@ -20,57 +20,60 @@ class SmsApi(models.AbstractModel):
|
||||
return iap_tools.iap_jsonrpc(endpoint + local_endpoint, params=params)
|
||||
|
||||
@api.model
|
||||
def _send_sms(self, numbers, message):
|
||||
""" Send a single message to several numbers
|
||||
|
||||
:param numbers: list of E164 formatted phone numbers
|
||||
:param message: content to send
|
||||
|
||||
:raises ? TDE FIXME
|
||||
"""
|
||||
params = {
|
||||
'numbers': numbers,
|
||||
'message': message,
|
||||
}
|
||||
return self._contact_iap('/iap/message_send', params)
|
||||
|
||||
@api.model
|
||||
def _send_sms_batch(self, messages):
|
||||
def _send_sms_batch(self, messages, dlr=False):
|
||||
""" Send SMS using IAP in batch mode
|
||||
|
||||
:param messages: list of SMS to send, structured as dict [{
|
||||
'res_id': integer: ID of sms.sms,
|
||||
'number': string: E164 formatted phone number,
|
||||
'content': string: content to send
|
||||
}]
|
||||
|
||||
:return: return of /iap/sms/1/send controller which is a list of dict [{
|
||||
'res_id': integer: ID of sms.sms,
|
||||
'state': string: 'insufficient_credit' or 'wrong_number_format' or 'success',
|
||||
'credit': integer: number of credits spent to send this SMS,
|
||||
}]
|
||||
|
||||
:raises: normally none
|
||||
:param list messages: list of SMS (grouped by content) to send:
|
||||
formatted as ```[
|
||||
{
|
||||
'content' : str,
|
||||
'numbers' : [
|
||||
{ 'uuid' : str, 'number' : str },
|
||||
{ 'uuid' : str, 'number' : str },
|
||||
...
|
||||
]
|
||||
}, ...
|
||||
]```
|
||||
:param bool dlr: whether to receive delivery reports or not
|
||||
:return: response from the endpoint called, which is a list of results
|
||||
formatted as ```[
|
||||
{
|
||||
uuid: UUID of the request,
|
||||
state: ONE of: {
|
||||
'success', 'processing', 'server_error', 'unregistered', 'insufficient_credit',
|
||||
'wrong_number_format', 'duplicate_message', 'country_not_supported', 'registration_needed',
|
||||
},
|
||||
credit: Optional: Credits spent to send SMS (provided if the actual price is known)
|
||||
}, ...
|
||||
]```
|
||||
"""
|
||||
params = {
|
||||
'messages': messages
|
||||
'messages': messages,
|
||||
'webhook_url': dlr and (self.get_base_url() + '/sms/status')
|
||||
}
|
||||
return self._contact_iap('/iap/sms/2/send', params)
|
||||
return self._contact_iap('/iap/sms/3/send', params)
|
||||
|
||||
@api.model
|
||||
def _get_sms_api_error_messages(self):
|
||||
""" Returns a dict containing the error message to display for every known error 'state'
|
||||
resulting from the '_send_sms_batch' method.
|
||||
We prefer a dict instead of a message-per-error-state based method so we only call
|
||||
the 'get_credits_url' once, to avoid extra RPC calls. """
|
||||
"""Return a mapping of `_send_sms_batch` errors to an error message.
|
||||
|
||||
buy_credits_url = self.sudo().env['iap.account'].get_credits_url(service_name='sms')
|
||||
buy_credits = '<a href="%s" target="_blank">%s</a>' % (
|
||||
buy_credits_url,
|
||||
_('Buy credits.')
|
||||
We prefer a dict instead of a message-per-error-state based method so that we only call
|
||||
config parameters getters once and avoid extra RPC calls."""
|
||||
buy_credits_url = self.env['iap.account'].sudo().get_credits_url(service_name='sms')
|
||||
buy_credits = f'<a href="{buy_credits_url}" target="_blank">%s</a>' % _('Buy credits.')
|
||||
|
||||
sms_endpoint = self.env['ir.config_parameter'].sudo().get_param('sms.endpoint', DEFAULT_ENDPOINT)
|
||||
sms_account_token = self.env['iap.account'].sudo().get('sms').account_token
|
||||
register_now = f'<a href="{sms_endpoint}/1/account?account_token={sms_account_token}" target="_blank">%s</a>' % (
|
||||
_('Register now.')
|
||||
)
|
||||
|
||||
return {
|
||||
'unregistered': _("You don't have an eligible IAP account."),
|
||||
'insufficient_credit': ' '.join([_('You don\'t have enough credits on your IAP account.'), buy_credits]),
|
||||
'insufficient_credit': ' '.join([_("You don't have enough credits on your IAP account."), buy_credits]),
|
||||
'wrong_number_format': _("The number you're trying to reach is not correctly formatted."),
|
||||
'duplicate_message': _("This SMS has been removed as the number was already used."),
|
||||
'country_not_supported': _("The destination country is not supported."),
|
||||
'incompatible_content': _("The content of the message violates rules applied by our providers."),
|
||||
'registration_needed': ' '.join([_("Country-specific registration required."), register_now]),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
|
||||
@@ -15,75 +16,74 @@ class SmsSms(models.Model):
|
||||
_rec_name = 'number'
|
||||
_order = 'id DESC'
|
||||
|
||||
IAP_TO_SMS_STATE = {
|
||||
'success': 'sent',
|
||||
IAP_TO_SMS_STATE_SUCCESS = {
|
||||
'processing': 'process',
|
||||
'success': 'pending',
|
||||
# These below are not returned in responses from IAP API in _send but are received via webhook events.
|
||||
'sent': 'pending',
|
||||
'delivered': 'sent',
|
||||
}
|
||||
IAP_TO_SMS_FAILURE_TYPE = {
|
||||
'insufficient_credit': 'sms_credit',
|
||||
'wrong_number_format': 'sms_number_format',
|
||||
'country_not_supported': 'sms_country_not_supported',
|
||||
'server_error': 'sms_server',
|
||||
'unregistered': 'sms_acc'
|
||||
}
|
||||
|
||||
BOUNCE_DELIVERY_ERRORS = {'sms_invalid_destination', 'sms_not_allowed', 'sms_rejected'}
|
||||
DELIVERY_ERRORS = {'sms_expired', 'sms_not_delivered', *BOUNCE_DELIVERY_ERRORS}
|
||||
|
||||
uuid = fields.Char('UUID', copy=False, readonly=True, default=lambda self: uuid4().hex,
|
||||
help='Alternate way to identify a SMS record, used for delivery reports')
|
||||
number = fields.Char('Number')
|
||||
body = fields.Text()
|
||||
partner_id = fields.Many2one('res.partner', 'Customer')
|
||||
mail_message_id = fields.Many2one('mail.message', index=True)
|
||||
state = fields.Selection([
|
||||
('outgoing', 'In Queue'),
|
||||
('sent', 'Sent'),
|
||||
('process', 'Processing'),
|
||||
('pending', 'Sent'),
|
||||
('sent', 'Delivered'), # As for notifications and traces
|
||||
('error', 'Error'),
|
||||
('canceled', 'Canceled')
|
||||
], 'SMS Status', readonly=True, copy=False, default='outgoing', required=True)
|
||||
failure_type = fields.Selection([
|
||||
("unknown", "Unknown error"),
|
||||
('sms_number_missing', 'Missing Number'),
|
||||
('sms_number_format', 'Wrong Number Format'),
|
||||
('sms_country_not_supported', 'Country Not Supported'),
|
||||
('sms_registration_needed', 'Country-specific Registration Required'),
|
||||
('sms_credit', 'Insufficient Credit'),
|
||||
('sms_server', 'Server Error'),
|
||||
('sms_acc', 'Unregistered Account'),
|
||||
# mass mode specific codes
|
||||
# mass mode specific codes, generated internally, not returned by IAP.
|
||||
('sms_blacklist', 'Blacklisted'),
|
||||
('sms_duplicate', 'Duplicate'),
|
||||
('sms_optout', 'Opted Out'),
|
||||
], copy=False)
|
||||
sms_tracker_id = fields.Many2one('sms.tracker', string='SMS trackers', compute='_compute_sms_tracker_id')
|
||||
|
||||
_sql_constraints = [
|
||||
('uuid_unique', 'unique(uuid)', 'UUID must be unique'),
|
||||
]
|
||||
|
||||
@api.depends('uuid')
|
||||
def _compute_sms_tracker_id(self):
|
||||
self.sms_tracker_id = False
|
||||
existing_trackers = self.env['sms.tracker'].search([('sms_uuid', 'in', self.filtered('uuid').mapped('uuid'))])
|
||||
tracker_ids_by_sms_uuid = {tracker.sms_uuid: tracker.id for tracker in existing_trackers}
|
||||
for sms in self.filtered(lambda s: s.uuid in tracker_ids_by_sms_uuid):
|
||||
sms.sms_tracker_id = tracker_ids_by_sms_uuid[sms.uuid]
|
||||
|
||||
def action_set_canceled(self):
|
||||
self.state = 'canceled'
|
||||
notifications = self.env['mail.notification'].sudo().search([
|
||||
('sms_id', 'in', self.ids),
|
||||
# sent is sent -> cannot reset
|
||||
('notification_status', 'not in', ['canceled', 'sent']),
|
||||
])
|
||||
if notifications:
|
||||
notifications.write({'notification_status': 'canceled'})
|
||||
if not self._context.get('sms_skip_msg_notification', False):
|
||||
notifications.mail_message_id._notify_message_notification_update()
|
||||
self._update_sms_state_and_trackers('canceled')
|
||||
|
||||
def action_set_error(self, failure_type):
|
||||
self.state = 'error'
|
||||
self.failure_type = failure_type
|
||||
notifications = self.env['mail.notification'].sudo().search([
|
||||
('sms_id', 'in', self.ids),
|
||||
# sent can be set to error due to IAP feedback
|
||||
('notification_status', '!=', 'exception'),
|
||||
])
|
||||
if notifications:
|
||||
notifications.write({'notification_status': 'exception', 'failure_type': failure_type})
|
||||
if not self._context.get('sms_skip_msg_notification', False):
|
||||
notifications.mail_message_id._notify_message_notification_update()
|
||||
self._update_sms_state_and_trackers('error', failure_type=failure_type)
|
||||
|
||||
def action_set_outgoing(self):
|
||||
self.write({
|
||||
'state': 'outgoing',
|
||||
'failure_type': False
|
||||
})
|
||||
notifications = self.env['mail.notification'].sudo().search([
|
||||
('sms_id', 'in', self.ids),
|
||||
# sent is sent -> cannot reset
|
||||
('notification_status', 'not in', ['ready', 'sent']),
|
||||
])
|
||||
if notifications:
|
||||
notifications.write({'notification_status': 'ready', 'failure_type': False})
|
||||
if not self._context.get('sms_skip_msg_notification', False):
|
||||
notifications.mail_message_id._notify_message_notification_update()
|
||||
self._update_sms_state_and_trackers('outgoing', failure_type=False)
|
||||
|
||||
def send(self, unlink_failed=False, unlink_sent=True, auto_commit=False, raise_exception=False):
|
||||
""" Main API method to send SMS.
|
||||
@@ -159,59 +159,48 @@ class SmsSms(models.Model):
|
||||
yield sms_batch
|
||||
|
||||
def _send(self, unlink_failed=False, unlink_sent=True, raise_exception=False):
|
||||
""" This method tries to send SMS after checking the number (presence and
|
||||
formatting). """
|
||||
iap_data = [{
|
||||
'res_id': record.id,
|
||||
'number': record.number,
|
||||
'content': record.body,
|
||||
} for record in self]
|
||||
"""Send SMS after checking the number (presence and formatting)."""
|
||||
messages = [{
|
||||
'content': body,
|
||||
'numbers': [{'number': sms.number, 'uuid': sms.uuid} for sms in body_sms_records],
|
||||
} for body, body_sms_records in self.grouped('body').items()]
|
||||
|
||||
try:
|
||||
iap_results = self.env['sms.api']._send_sms_batch(iap_data)
|
||||
results = self.env['sms.api']._send_sms_batch(messages, dlr=True)
|
||||
except Exception as e:
|
||||
_logger.info('Sent batch %s SMS: %s: failed with exception %s', len(self.ids), self.ids, e)
|
||||
if raise_exception:
|
||||
raise
|
||||
self._postprocess_iap_sent_sms(
|
||||
[{'res_id': sms.id, 'state': 'server_error'} for sms in self],
|
||||
unlink_failed=unlink_failed, unlink_sent=unlink_sent)
|
||||
results = [{'uuid': sms.uuid, 'state': 'server_error'} for sms in self]
|
||||
else:
|
||||
_logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, iap_results)
|
||||
self._postprocess_iap_sent_sms(iap_results, unlink_failed=unlink_failed, unlink_sent=unlink_sent)
|
||||
_logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, results)
|
||||
|
||||
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, unlink_failed=False, unlink_sent=True):
|
||||
todelete_sms_ids = []
|
||||
if unlink_failed:
|
||||
todelete_sms_ids += [item['res_id'] for item in iap_results if item['state'] != 'success']
|
||||
if unlink_sent:
|
||||
todelete_sms_ids += [item['res_id'] for item in iap_results if item['state'] == 'success']
|
||||
results_uuids = [result['uuid'] for result in results]
|
||||
all_sms_sudo = self.env['sms.sms'].sudo().search([('uuid', 'in', results_uuids)]).with_context(sms_skip_msg_notification=True)
|
||||
mail_message_ids_sudo = all_sms_sudo.mail_message_id
|
||||
|
||||
for state in self.IAP_TO_SMS_STATE.keys():
|
||||
sms_ids = [item['res_id'] for item in iap_results if item['state'] == state]
|
||||
if sms_ids:
|
||||
if state != 'success' and not unlink_failed:
|
||||
self.env['sms.sms'].sudo().browse(sms_ids).write({
|
||||
'state': 'error',
|
||||
'failure_type': self.IAP_TO_SMS_STATE[state],
|
||||
})
|
||||
if state == 'success' and not unlink_sent:
|
||||
self.env['sms.sms'].sudo().browse(sms_ids).write({
|
||||
'state': 'sent',
|
||||
'failure_type': False,
|
||||
})
|
||||
notifications = self.env['mail.notification'].sudo().search([
|
||||
('notification_type', '=', 'sms'),
|
||||
('sms_id', 'in', sms_ids),
|
||||
('notification_status', 'not in', ('sent', 'canceled')),
|
||||
])
|
||||
if notifications:
|
||||
notifications.write({
|
||||
'notification_status': 'sent' if state == 'success' else 'exception',
|
||||
'failure_type': self.IAP_TO_SMS_STATE[state] if state != 'success' else False,
|
||||
'failure_reason': failure_reason if failure_reason else False,
|
||||
})
|
||||
self.mail_message_id._notify_message_notification_update()
|
||||
for iap_state, results_group in tools.groupby(results, key=lambda result: result['state']):
|
||||
sms_sudo = all_sms_sudo.filtered(lambda s: s.uuid in {result['uuid'] for result in results_group})
|
||||
if success_state := self.IAP_TO_SMS_STATE_SUCCESS.get(iap_state):
|
||||
sms_sudo.sms_tracker_id._action_update_from_sms_state(success_state)
|
||||
if unlink_sent:
|
||||
sms_sudo.unlink()
|
||||
else:
|
||||
sms_sudo.write({'state': success_state, 'failure_type': False})
|
||||
else:
|
||||
failure_type = self.IAP_TO_SMS_FAILURE_TYPE.get(iap_state, 'unknown')
|
||||
if failure_type != 'unknown':
|
||||
sms_sudo.sms_tracker_id._action_update_from_sms_state('error', failure_type=failure_type)
|
||||
else:
|
||||
sms_sudo.sms_tracker_id._action_update_from_provider_error(iap_state)
|
||||
if unlink_failed:
|
||||
sms_sudo.unlink()
|
||||
else:
|
||||
sms_sudo.write({'state': 'error', 'failure_type': failure_type})
|
||||
|
||||
if todelete_sms_ids:
|
||||
self.browse(todelete_sms_ids).sudo().unlink()
|
||||
mail_message_ids_sudo._notify_message_notification_update()
|
||||
|
||||
def _update_sms_state_and_trackers(self, new_state, failure_type=None):
|
||||
"""Update sms state update and related tracking records (notifications, traces)."""
|
||||
self.write({'state': new_state, 'failure_type': failure_type})
|
||||
self.sms_tracker_id._action_update_from_sms_state(new_state, failure_type=failure_type)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SmsTracker(models.Model):
|
||||
"""Relationship between a sent SMS and tracking records such as notifications and traces.
|
||||
|
||||
This model acts as an extension of a `mail.notification` or a `mailing.trace` and allows to
|
||||
update those based on the SMS provider responses both at sending and when later receiving
|
||||
sent/delivery reports (see `SmsController`).
|
||||
SMS trackers are supposed to be created manually when necessary, and tied to their related
|
||||
SMS through the SMS UUID field. (They are not tied to the SMS records directly as those can
|
||||
be deleted when sent).
|
||||
|
||||
Note: Only admins/system user should need to access (a fortiori modify) these technical
|
||||
records so no "sudo" is used nor should be required here.
|
||||
"""
|
||||
_name = 'sms.tracker'
|
||||
_description = "Link SMS to mailing/sms tracking models"
|
||||
|
||||
SMS_STATE_TO_NOTIFICATION_STATUS = {
|
||||
'canceled': 'canceled',
|
||||
'process': 'process',
|
||||
'error': 'exception',
|
||||
'outgoing': 'ready',
|
||||
'sent': 'sent',
|
||||
'pending': 'pending',
|
||||
}
|
||||
|
||||
sms_uuid = fields.Char('SMS uuid', required=True)
|
||||
mail_notification_id = fields.Many2one('mail.notification', ondelete='cascade')
|
||||
|
||||
_sql_constraints = [
|
||||
('sms_uuid_unique', 'unique(sms_uuid)', 'A record for this UUID already exists'),
|
||||
]
|
||||
|
||||
def _action_update_from_provider_error(self, provider_error):
|
||||
"""
|
||||
:param str provider_error: value returned by SMS service provider (IAP) or any string.
|
||||
If provided, notification values will be derived from it.
|
||||
(see ``_get_tracker_values_from_provider_error``)
|
||||
"""
|
||||
failure_reason = False
|
||||
failure_type = f'sms_{provider_error}'
|
||||
error_status = None
|
||||
if failure_type not in self.env['sms.sms'].DELIVERY_ERRORS:
|
||||
failure_type = 'unknown'
|
||||
failure_reason = provider_error
|
||||
elif failure_type in self.env['sms.sms'].BOUNCE_DELIVERY_ERRORS:
|
||||
error_status = "bounce"
|
||||
|
||||
self._update_sms_notifications(error_status or 'exception', failure_type=failure_type, failure_reason=failure_reason)
|
||||
return error_status, failure_type, failure_reason
|
||||
|
||||
def _action_update_from_sms_state(self, sms_state, failure_type=False, failure_reason=False):
|
||||
notification_status = self.SMS_STATE_TO_NOTIFICATION_STATUS[sms_state]
|
||||
self._update_sms_notifications(notification_status, failure_type=failure_type, failure_reason=failure_reason)
|
||||
|
||||
def _update_sms_notifications(self, notification_status, failure_type=False, failure_reason=False):
|
||||
# canceled is a state which means that the SMS sending order should not be sent to the SMS service.
|
||||
# `process`, `pending` are sent to IAP which is not revertible (as `sent` which means "delivered").
|
||||
notifications_statuses_to_ignore = {
|
||||
'canceled': ['canceled', 'process', 'pending', 'sent'],
|
||||
'ready': ['ready', 'process', 'pending', 'sent'],
|
||||
'process': ['process', 'pending', 'sent'],
|
||||
'pending': ['pending', 'sent'],
|
||||
'bounce': ['bounce', 'sent'],
|
||||
'sent': ['sent'],
|
||||
'exception': ['exception'],
|
||||
}[notification_status]
|
||||
notifications = self.mail_notification_id.filtered(
|
||||
lambda n: n.notification_status not in notifications_statuses_to_ignore
|
||||
)
|
||||
if notifications:
|
||||
notifications.write({
|
||||
'notification_status': notification_status,
|
||||
'failure_type': failure_type,
|
||||
'failure_reason': failure_reason,
|
||||
})
|
||||
if not self.env.context.get('sms_skip_msg_notification'):
|
||||
notifications.mail_message_id._notify_message_notification_update()
|
||||
@@ -4,6 +4,8 @@ access_sms_sms_system,access.sms.sms.system,model_sms_sms,base.group_system,1,1,
|
||||
access_sms_template_all,access.sms.template.all,model_sms_template,,0,0,0,0
|
||||
access_sms_template_user,access.sms.template.user,model_sms_template,base.group_user,1,0,0,0
|
||||
access_sms_template_system,access.sms.template.system,model_sms_template,base.group_system,1,1,1,1
|
||||
access_sms_tracker_all,access.sms.tracker.all,model_sms_tracker,,0,0,0,0
|
||||
access_sms_tracker_system,access.sms.tracker.system,model_sms_tracker,base.group_system,1,1,1,1
|
||||
access_sms_composer,access.sms.composer,model_sms_composer,base.group_user,1,1,1,0
|
||||
access_sms_resend_recipient,access.sms.resend.recipient,model_sms_resend_recipient,base.group_user,1,1,1,0
|
||||
access_sms_resend,access.sms.resend,model_sms_resend,base.group_user,1,1,1,0
|
||||
|
||||
|
@@ -9,37 +9,49 @@ import { click, contains } from "@web/../tests/utils";
|
||||
|
||||
QUnit.module("message (patch)");
|
||||
|
||||
QUnit.test("Notification Sent", async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "Someone", partner_share: true });
|
||||
const messageId = pyEnv["mail.message"].create({
|
||||
body: "not empty",
|
||||
message_type: "sms",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
pyEnv["mail.notification"].create({
|
||||
mail_message_id: messageId,
|
||||
notification_status: "sent",
|
||||
notification_type: "sms",
|
||||
res_partner_id: partnerId,
|
||||
});
|
||||
QUnit.test("Notification Processing", async (assert) => {
|
||||
const { partnerId } = await _prepareSmsNotification("process");
|
||||
const { openFormView } = await start();
|
||||
openFormView("res.partner", partnerId);
|
||||
await contains(".o-mail-Message");
|
||||
await contains(".o-mail-Message-notification");
|
||||
await contains(".o-mail-Message-notification i");
|
||||
assert.hasClass($(".o-mail-Message-notification i"), "fa-mobile");
|
||||
await openFormView("res.partner", partnerId);
|
||||
await _assertContainsSmsNotification(assert);
|
||||
await _assertContainsPopoverWithIcon(assert, "fa-hourglass-half");
|
||||
});
|
||||
|
||||
await click(".o-mail-Message-notification");
|
||||
await contains(".o-mail-MessageNotificationPopover");
|
||||
await contains(".o-mail-MessageNotificationPopover i");
|
||||
assert.hasClass($(".o-mail-MessageNotificationPopover i"), "fa-check");
|
||||
await contains(".o-mail-MessageNotificationPopover", { text: "Someone" });
|
||||
QUnit.test("Notification Pending", async (assert) => {
|
||||
const { partnerId } = await _prepareSmsNotification("pending");
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await _assertContainsSmsNotification(assert);
|
||||
await _assertContainsPopoverWithIcon(assert, "fa-paper-plane-o");
|
||||
});
|
||||
|
||||
QUnit.test("Notification Sent", async (assert) => {
|
||||
const { partnerId } = await _prepareSmsNotification("sent");
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await _assertContainsPopoverWithIcon(assert, "fa-check");
|
||||
});
|
||||
|
||||
QUnit.test("Notification Error", async (assert) => {
|
||||
const openResendActionDef = makeDeferred();
|
||||
const { partnerId, messageId } = await _prepareSmsNotification("exception");
|
||||
const { env, openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
patchWithCleanup(env.services.action, {
|
||||
doAction(action, options) {
|
||||
assert.strictEqual(action, "sms.sms_resend_action");
|
||||
assert.strictEqual(options.additionalContext.default_mail_message_id, messageId);
|
||||
assert.step("do_action");
|
||||
openResendActionDef.resolve();
|
||||
},
|
||||
});
|
||||
await _assertContainsSmsNotification(assert);
|
||||
await click(".o-mail-Message-notification");
|
||||
await openResendActionDef;
|
||||
assert.verifySteps(["do_action"]);
|
||||
});
|
||||
|
||||
const _prepareSmsNotification = async (notification_status) => {
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "Someone", partner_share: true });
|
||||
const messageId = pyEnv["mail.message"].create({
|
||||
@@ -50,26 +62,24 @@ QUnit.test("Notification Error", async (assert) => {
|
||||
});
|
||||
pyEnv["mail.notification"].create({
|
||||
mail_message_id: messageId,
|
||||
notification_status: "exception",
|
||||
notification_status: notification_status,
|
||||
notification_type: "sms",
|
||||
res_partner_id: partnerId,
|
||||
});
|
||||
const { env, openFormView } = await start();
|
||||
openFormView("res.partner", partnerId);
|
||||
patchWithCleanup(env.services.action, {
|
||||
doAction(action, options) {
|
||||
assert.step("do_action");
|
||||
assert.strictEqual(action, "sms.sms_resend_action");
|
||||
assert.strictEqual(options.additionalContext.default_mail_message_id, messageId);
|
||||
openResendActionDef.resolve();
|
||||
},
|
||||
});
|
||||
return { partnerId, messageId };
|
||||
};
|
||||
|
||||
const _assertContainsSmsNotification = async (assert) => {
|
||||
await contains(".o-mail-Message");
|
||||
await contains(".o-mail-Message-notification");
|
||||
await contains(".o-mail-Message-notification i");
|
||||
assert.hasClass($(".o-mail-Message-notification i"), "fa-mobile");
|
||||
};
|
||||
|
||||
const _assertContainsPopoverWithIcon = async (assert, iconClassName) => {
|
||||
await click(".o-mail-Message-notification");
|
||||
await openResendActionDef;
|
||||
assert.verifySteps(["do_action"]);
|
||||
});
|
||||
await contains(".o-mail-MessageNotificationPopover");
|
||||
await contains(".o-mail-MessageNotificationPopover i");
|
||||
assert.hasClass($(".o-mail-MessageNotificationPopover i"), iconClassName);
|
||||
await contains(".o-mail-MessageNotificationPopover", { text: "Someone" });
|
||||
};
|
||||
|
||||
+45
-23
@@ -18,7 +18,7 @@ class MockSMS(common.BaseCase):
|
||||
self._clear_sms_sent()
|
||||
|
||||
@contextmanager
|
||||
def mockSMSGateway(self, sms_allow_unlink=False, sim_error=None, nbr_t_error=None):
|
||||
def mockSMSGateway(self, sms_allow_unlink=False, sim_error=None, nbr_t_error=None, moderated=False):
|
||||
self._clear_sms_sent()
|
||||
sms_create_origin = SmsSms.create
|
||||
sms_send_origin = SmsSms._send
|
||||
@@ -39,12 +39,8 @@ class MockSMS(common.BaseCase):
|
||||
error = sim_error or (nbr_t_error and nbr_t_error.get(to_send['number']))
|
||||
if error and error == 'credit':
|
||||
res.update(credit=0, state='insufficient_credit')
|
||||
elif error and error == 'wrong_number_format':
|
||||
res.update(state='wrong_number_format')
|
||||
elif error and error == 'unregistered':
|
||||
res.update(state='unregistered')
|
||||
elif error and error == 'server_error':
|
||||
res.update(state='server_error')
|
||||
elif error and error in {'wrong_number_format', 'unregistered', 'server_error'}:
|
||||
res.update(state=error)
|
||||
elif error and error == 'jsonrpc_exception':
|
||||
raise exceptions.AccessError(
|
||||
'The url that this service requested returned an error. Please contact the author of the app. The url it tried to contact was ' + local_endpoint
|
||||
@@ -56,6 +52,35 @@ class MockSMS(common.BaseCase):
|
||||
'body': to_send['content'],
|
||||
})
|
||||
return result
|
||||
elif local_endpoint == '/iap/sms/3/send':
|
||||
result = []
|
||||
for message in params['messages']:
|
||||
for number in message["numbers"]:
|
||||
error = sim_error or (nbr_t_error and nbr_t_error.get(number['number']))
|
||||
if error == 'jsonrpc_exception':
|
||||
raise exceptions.AccessError(
|
||||
'The url that this service requested returned an error. '
|
||||
'Please contact the author of the app. '
|
||||
'The url it tried to contact was ' + local_endpoint
|
||||
)
|
||||
elif error == 'credit':
|
||||
error = 'insufficient_credit'
|
||||
res = {
|
||||
'uuid': number['uuid'],
|
||||
'state': error if error else 'success' if not moderated else 'processing',
|
||||
'credit': 1,
|
||||
}
|
||||
if error:
|
||||
# credit is only given if the amount is known
|
||||
res.update(credit=0)
|
||||
else:
|
||||
self._sms.append({
|
||||
'number': number['number'],
|
||||
'body': message['content'],
|
||||
'uuid': number['uuid'],
|
||||
})
|
||||
result.append(res)
|
||||
return result
|
||||
|
||||
def _sms_sms_create(model, *args, **kwargs):
|
||||
res = sms_create_origin(model, *args, **kwargs)
|
||||
@@ -65,9 +90,7 @@ class MockSMS(common.BaseCase):
|
||||
def _sms_sms_send(records, unlink_failed=False, unlink_sent=True, raise_exception=False):
|
||||
if sms_allow_unlink:
|
||||
return sms_send_origin(records, unlink_failed=unlink_failed, unlink_sent=unlink_sent, raise_exception=raise_exception)
|
||||
else:
|
||||
return sms_send_origin(records, unlink_failed=False, unlink_sent=False, raise_exception=raise_exception)
|
||||
return True
|
||||
return sms_send_origin(records, unlink_failed=False, unlink_sent=False, raise_exception=raise_exception)
|
||||
|
||||
try:
|
||||
with patch.object(SmsApi, '_contact_iap', side_effect=_contact_iap), \
|
||||
@@ -81,7 +104,7 @@ class MockSMS(common.BaseCase):
|
||||
self._sms = []
|
||||
self._new_sms = self.env['sms.sms'].sudo()
|
||||
|
||||
def _clear_outoing_sms(self):
|
||||
def _clear_outgoing_sms(self):
|
||||
""" As SMS gateway mock keeps SMS, we may need to remove them manually
|
||||
if there are several tests in the same tx. """
|
||||
self.env['sms.sms'].sudo().search([('state', '=', 'outgoing')]).unlink()
|
||||
@@ -116,8 +139,8 @@ class SMSCase(MockSMS):
|
||||
return sms
|
||||
|
||||
def assertNoSMS(self):
|
||||
""" Check no sms went through gateway during mock. """
|
||||
self.assertTrue(len(self._new_sms) == 0)
|
||||
""" Check no sms persisted during mock. """
|
||||
self.assertEqual(len(self._new_sms.exists()), 0)
|
||||
|
||||
def assertSMSIapSent(self, numbers, content=None):
|
||||
""" Check sent SMS. Order is not checked. Each number should have received
|
||||
@@ -132,10 +155,6 @@ class SMSCase(MockSMS):
|
||||
if content is not None:
|
||||
self.assertIn(content, sent_sms['body'])
|
||||
|
||||
def assertSMSSent(self, numbers, content=None):
|
||||
""" Deprecated. Remove in 14.4 """
|
||||
return self.assertSMSIapSent(numbers, content=content)
|
||||
|
||||
def assertSMS(self, partner, number, status, failure_type=None,
|
||||
content=None, fields_values=None):
|
||||
""" Find a ``sms.sms`` record, based on given partner, number and status.
|
||||
@@ -158,7 +177,7 @@ class SMSCase(MockSMS):
|
||||
self.assertEqual(
|
||||
sms_sms[fname], fvalue,
|
||||
'SMS: expected %s for %s, got %s' % (fvalue, fname, sms_sms[fname]))
|
||||
if status == 'sent':
|
||||
if status == 'pending':
|
||||
self.assertSMSIapSent([sms_sms.number], content=content)
|
||||
|
||||
def assertSMSCanceled(self, partner, number, failure_type, content=None, fields_values=None):
|
||||
@@ -189,7 +208,7 @@ class SMSCase(MockSMS):
|
||||
:param recipients_info: list[{
|
||||
'partner': res.partner record (may be empty),
|
||||
'number': number used for notification (may be empty, computed based on partner),
|
||||
'state': ready / sent / exception / canceled (sent by default),
|
||||
'state': ready / pending / sent / exception / canceled (pending by default),
|
||||
'failure_type': optional: sms_number_missing / sms_number_format / sms_credit / sms_server
|
||||
}, { ... }]
|
||||
"""
|
||||
@@ -212,7 +231,7 @@ class SMSCase(MockSMS):
|
||||
for recipient_info in recipients_info:
|
||||
partner = recipient_info.get('partner', self.env['res.partner'])
|
||||
number = recipient_info.get('number')
|
||||
state = recipient_info.get('state', 'sent')
|
||||
state = recipient_info.get('state', 'pending')
|
||||
if number is None and partner:
|
||||
number = partner._phone_format()
|
||||
|
||||
@@ -220,14 +239,14 @@ class SMSCase(MockSMS):
|
||||
self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)' % (partner, number, state))
|
||||
self.assertEqual(notif.author_id, notif.mail_message_id.author_id, 'SMS: Message and notification should have the same author')
|
||||
|
||||
if state not in ('sent', 'ready', 'canceled'):
|
||||
if state not in {'process', 'sent', 'ready', 'canceled', 'pending'}:
|
||||
self.assertEqual(notif.failure_type, recipient_info['failure_type'])
|
||||
if check_sms:
|
||||
if state == 'sent':
|
||||
if state in {'process', 'pending', 'sent'}:
|
||||
if sent_unlink:
|
||||
self.assertSMSIapSent([number], content=content)
|
||||
else:
|
||||
self.assertSMS(partner, number, 'sent', content=content)
|
||||
self.assertSMS(partner, number, state, content=content)
|
||||
elif state == 'ready':
|
||||
self.assertSMS(partner, number, 'outgoing', content=content)
|
||||
elif state == 'exception':
|
||||
@@ -277,3 +296,6 @@ class SMSCommon(MailCommon, SMSCase):
|
||||
'model_id': cls.env['ir.model']._get(model).id,
|
||||
'body': body if body else 'Dear {{ object.display_name }} this is an SMS.'
|
||||
})
|
||||
|
||||
def _make_webhook_jsonrpc_request(self, statuses):
|
||||
return self.make_jsonrpc_request('/sms/status', {'message_statuses': statuses})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button name="send" string="Send Now" type="object" invisible="state != 'outgoing'" class="oe_highlight"/>
|
||||
<button name="action_set_outgoing" string="Retry" type="object" invisible="state not in ('error', 'canceled')"/>
|
||||
<button name="action_set_canceled" string="Cancel" type="object" invisible="state not in ('error', 'outgoing')"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="outgoing,sent,error,canceled"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
@@ -23,7 +23,8 @@
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="body" widget="sms_widget" string="Message" required="1"/>
|
||||
<field name="body" widget="sms_widget" string="Message" required="1"
|
||||
readonly="state in ('process', 'sent')"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from ast import literal_eval
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.phone_validation.tools import phone_validation
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import html2plaintext, plaintext2html
|
||||
|
||||
@@ -204,11 +204,8 @@ class SendSMS(models.TransientModel):
|
||||
return self._action_send_sms_mass(records)
|
||||
|
||||
def _action_send_sms_numbers(self):
|
||||
self.env['sms.api']._send_sms_batch([{
|
||||
'res_id': 0,
|
||||
'number': number,
|
||||
'content': self.body,
|
||||
} for number in self.sanitized_numbers.split(',')])
|
||||
sms_values = [{'body': self.body, 'number': number} for number in self.sanitized_numbers.split(',')]
|
||||
self.env['sms.sms'].sudo().create(sms_values).send()
|
||||
return True
|
||||
|
||||
def _action_send_sms_comment_single(self, records=None):
|
||||
@@ -243,7 +240,6 @@ class SendSMS(models.TransientModel):
|
||||
|
||||
sms_record_values = self._prepare_mass_sms_values(records)
|
||||
sms_all = self._prepare_mass_sms(records, sms_record_values)
|
||||
|
||||
if sms_all and self.mass_keep_log and records and issubclass(type(records), self.pool['mail.thread']):
|
||||
log_values = self._prepare_mass_log_values(records, sms_record_values)
|
||||
records._message_log_batch(**log_values)
|
||||
@@ -322,10 +318,11 @@ class SendSMS(models.TransientModel):
|
||||
|
||||
result[record.id] = {
|
||||
'body': all_bodies[record.id],
|
||||
'partner_id': recipients['partner'].id,
|
||||
'number': sanitized if sanitized else recipients['number'],
|
||||
'state': state,
|
||||
'failure_type': failure_type,
|
||||
'number': sanitized if sanitized else recipients['number'],
|
||||
'partner_id': recipients['partner'].id,
|
||||
'state': state,
|
||||
'uuid': uuid4().hex,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from . import test_mail_thread_phone
|
||||
from . import test_phone_blacklist
|
||||
from . import test_sms_composer
|
||||
from . import test_sms_controller
|
||||
from . import test_sms_management
|
||||
from . import test_sms_performance
|
||||
from . import test_sms_post
|
||||
|
||||
@@ -43,10 +43,10 @@ class TestSMSComposerComment(SMSCommon, TestSMSRecipients):
|
||||
'numbers': ','.join(self.random_numbers),
|
||||
})
|
||||
|
||||
with self.mockSMSGateway():
|
||||
with self.mockSMSGateway(sms_allow_unlink=True):
|
||||
composer._action_send_sms()
|
||||
|
||||
# use sms.api directly, does not create sms.sms
|
||||
# sms.sms was deleted as successfully sent
|
||||
self.assertNoSMS()
|
||||
self.assertSMSIapSent(self.random_numbers_san, self._test_body)
|
||||
|
||||
@@ -259,10 +259,10 @@ class TestSMSComposerComment(SMSCommon, TestSMSRecipients):
|
||||
'numbers': ','.join(self.random_numbers),
|
||||
})
|
||||
|
||||
with self.mockSMSGateway():
|
||||
with self.mockSMSGateway(sms_allow_unlink=True):
|
||||
composer._action_send_sms()
|
||||
|
||||
# use sms.api directly, does not create sms.sms
|
||||
# sms.sms was deleted as successfully sent
|
||||
self.assertNoSMS()
|
||||
self.assertSMSIapSent(self.random_numbers_san, self._test_body)
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import Command
|
||||
from odoo.addons.test_mail_sms.tests.test_sms_management import TestSMSActionsCommon
|
||||
from odoo.tests.common import HttpCase, JsonRpcException
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
class TestSmsController(HttpCase, TestSMSActionsCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.sms_processing, cls.sms_sent = cls.env['sms.sms'].create([
|
||||
{
|
||||
'body': 'A test about pending state',
|
||||
'number': '10',
|
||||
'mail_message_id': cls.msg.id,
|
||||
'uuid': '8db55b6a9ec6443ca1d69af3ab500e27',
|
||||
'state': 'process',
|
||||
}, {
|
||||
'mail_message_id': cls.msg.id,
|
||||
'body': 'A test about sent state',
|
||||
'number': '20',
|
||||
'uuid': '8505e6f439d4472690c7955de9b210a4',
|
||||
'state': 'sent',
|
||||
},
|
||||
])
|
||||
cls.notification_processing, cls.notification_pending = cls.env['mail.notification'].create([
|
||||
{
|
||||
'mail_message_id': cls.msg.id,
|
||||
'notification_type': 'sms',
|
||||
'notification_status': 'process',
|
||||
'sms_id_int': cls.sms_processing.id,
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': cls.sms_processing.uuid})],
|
||||
}, {
|
||||
'mail_message_id': cls.msg.id,
|
||||
'notification_type': 'sms',
|
||||
'notification_status': 'pending',
|
||||
'sms_id_int': cls.sms_sent.id,
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': cls.sms_sent.uuid})],
|
||||
},
|
||||
])
|
||||
cls.sms_sent.unlink() # as it would normally be.
|
||||
|
||||
@mute_logger("odoo.addons.base.models.ir_http")
|
||||
def test_webhook_update_notification_from_processing_to_pending(self):
|
||||
self.assertTrue(self.sms_processing)
|
||||
statuses = [{'sms_status': 'sent', 'uuids': [self.sms_processing.uuid]}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertTrue(self.sms_processing.to_delete)
|
||||
self.assertEqual(self.notification_processing.notification_status, 'pending')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http')
|
||||
def test_webhook_update_notification_from_pending_to_bounced(self):
|
||||
statuses = [{'sms_status': 'invalid_destination', 'uuids': [self.notification_pending.sms_tracker_ids.sms_uuid]}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(self.notification_pending.notification_status, 'bounce')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http')
|
||||
def test_webhook_update_notification_from_pending_to_delivered(self):
|
||||
statuses = [{'sms_status': 'delivered', 'uuids': [self.notification_pending.sms_tracker_ids.sms_uuid]}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(self.notification_pending.notification_status, 'sent')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http')
|
||||
def test_webhook_update_notification_from_pending_to_failed(self):
|
||||
statuses = [{'sms_status': 'not_delivered', 'uuids': [self.notification_pending.sms_tracker_ids.sms_uuid]}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(self.notification_pending.notification_status, 'exception')
|
||||
self.assertEqual(self.notification_pending.failure_type, 'sms_not_delivered')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http')
|
||||
def test_webhook_update_notification_multiple_statuses(self):
|
||||
statuses = [
|
||||
{'sms_status': 'sent', 'uuids': [self.notification_processing.sms_tracker_ids.sms_uuid]},
|
||||
{'sms_status': 'delivered', 'uuids': [self.notification_pending.sms_tracker_ids.sms_uuid]}
|
||||
]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertTrue(self.sms_processing.to_delete)
|
||||
self.assertEqual(self.notification_processing.notification_status, 'pending')
|
||||
self.assertEqual(self.notification_pending.notification_status, 'sent')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http', 'odoo.addons.sms.controllers.main', 'odoo.http')
|
||||
def test_webhook_update_raises_with_wrong_event_data(self):
|
||||
statuses = [{'sms_status': 'delivered', 'uuids': ['not a uuid']}]
|
||||
with self.assertRaises(JsonRpcException):
|
||||
self._make_webhook_jsonrpc_request(statuses)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http')
|
||||
def test_webhook_update_succeeds_with_non_existent_uuids(self):
|
||||
statuses = [{'sms_status': 'delivered', 'uuids': ['00000000000000000000000000000000']}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_http')
|
||||
def test_webhook_update_succeeds_with_unknown_status(self):
|
||||
statuses = [{'sms_status': 'something_new', 'uuids': [self.notification_pending.sms_tracker_ids.sms_uuid]}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(self.notification_pending.notification_status, 'exception')
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import Command
|
||||
from odoo.addons.sms.tests.common import SMSCommon
|
||||
from odoo.addons.test_mail_sms.tests.common import TestSMSRecipients
|
||||
from odoo.tests import tagged
|
||||
@@ -22,6 +23,7 @@ class TestSMSActionsCommon(SMSCommon, TestSMSRecipients):
|
||||
'body': 'TEST BODY',
|
||||
'failure_type': 'sms_number_format',
|
||||
'mail_message_id': cls.msg.id,
|
||||
'uuid': 'e91d874e-d55f-4cf6-9d08-38ff912c6efd',
|
||||
'number': cls.partner_1.mobile,
|
||||
'partner_id': cls.partner_1.id,
|
||||
'state': 'error',
|
||||
@@ -32,6 +34,7 @@ class TestSMSActionsCommon(SMSCommon, TestSMSRecipients):
|
||||
'res_partner_id': cls.partner_1.id,
|
||||
'sms_id': cls.sms_p1.id,
|
||||
'sms_number': cls.partner_1.mobile,
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': cls.sms_p1.uuid})],
|
||||
'notification_type': 'sms',
|
||||
'notification_status': 'exception',
|
||||
'failure_type': 'sms_number_format',
|
||||
@@ -43,6 +46,7 @@ class TestSMSActionsCommon(SMSCommon, TestSMSRecipients):
|
||||
'number': cls.partner_2.mobile,
|
||||
'partner_id': cls.partner_2.id,
|
||||
'state': 'error',
|
||||
'uuid': 'bab41209-7b14-48c1-ae21-c45ceed7e728',
|
||||
})
|
||||
cls.notif_p2 = cls.env['mail.notification'].create({
|
||||
'author_id': cls.msg.author_id.id,
|
||||
@@ -50,6 +54,7 @@ class TestSMSActionsCommon(SMSCommon, TestSMSRecipients):
|
||||
'res_partner_id': cls.partner_2.id,
|
||||
'sms_id': cls.sms_p2.id,
|
||||
'sms_number': cls.partner_2.mobile,
|
||||
'sms_tracker_ids': [Command.create({'sms_uuid': cls.sms_p2.uuid})],
|
||||
'notification_type': 'sms',
|
||||
'notification_status': 'exception',
|
||||
'failure_type': 'sms_credit',
|
||||
@@ -89,7 +94,6 @@ class TestSMSActions(TestSMSActionsCommon):
|
||||
{'partner': self.partner_2, 'number': self.notif_p2.sms_number, 'state': 'canceled', 'failure_type': 'sms_credit'}
|
||||
], 'TEST BODY', self.msg, check_sms=False) # do not check new sms as they already exist
|
||||
|
||||
|
||||
def test_sms_set_error(self):
|
||||
self._reset_bus()
|
||||
(self.sms_p1 + self.sms_p2).with_context(sms_skip_msg_notification=True).action_set_canceled()
|
||||
@@ -134,8 +138,8 @@ class TestSMSWizards(TestSMSActionsCommon):
|
||||
wizard.action_resend()
|
||||
|
||||
self.assertSMSNotification([
|
||||
{'partner': self.partner_1, 'state': 'sent'},
|
||||
{'partner': self.partner_2, 'state': 'sent'}
|
||||
{'partner': self.partner_1, 'state': 'pending'},
|
||||
{'partner': self.partner_2, 'state': 'pending'}
|
||||
], 'TEST BODY', self.msg, check_sms=True)
|
||||
self.assertMessageBusNotifications(self.msg)
|
||||
|
||||
@@ -150,8 +154,8 @@ class TestSMSWizards(TestSMSActionsCommon):
|
||||
wizard.action_resend()
|
||||
|
||||
self.assertSMSNotification([
|
||||
{'partner': self.partner_1, 'state': 'sent', 'number': self.random_numbers_san[0]},
|
||||
{'partner': self.partner_2, 'state': 'sent', 'number': self.random_numbers_san[1]}
|
||||
{'partner': self.partner_1, 'state': 'pending', 'number': self.random_numbers_san[0]},
|
||||
{'partner': self.partner_2, 'state': 'pending', 'number': self.random_numbers_san[1]}
|
||||
], 'TEST BODY', self.msg, check_sms=True)
|
||||
self.assertMessageBusNotifications(self.msg)
|
||||
|
||||
@@ -195,6 +199,6 @@ class TestSMSWizards(TestSMSActionsCommon):
|
||||
with self.mockSMSGateway():
|
||||
wizard.action_resend()
|
||||
|
||||
self.assertSMSNotification([{'partner': self.partner_1, 'state': 'sent'}], 'TEST BODY', self.msg, check_sms=True)
|
||||
self.assertSMSNotification([{'partner': self.partner_1, 'state': 'pending'}], 'TEST BODY', self.msg, check_sms=True)
|
||||
self.assertSMSNotification([{'partner': self.partner_2, 'state': 'canceled', 'number': self.notif_p2.sms_number, 'failure_type': 'sms_credit'}], 'TEST BODY', self.msg, check_sms=False)
|
||||
self.assertMessageBusNotifications(self.msg)
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestSMSPerformance(BaseMailPerformance, sms_common.SMSCase):
|
||||
def test_message_sms_record_1_partner(self):
|
||||
record = self.test_record.with_user(self.env.user)
|
||||
pids = self.customer.ids
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=26):
|
||||
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=23):
|
||||
messages = record._message_sms(
|
||||
body='Performance Test',
|
||||
partner_ids=pids,
|
||||
@@ -50,7 +50,7 @@ class TestSMSPerformance(BaseMailPerformance, sms_common.SMSCase):
|
||||
def test_message_sms_record_10_partners(self):
|
||||
record = self.test_record.with_user(self.env.user)
|
||||
pids = self.partners.ids
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=26):
|
||||
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=23):
|
||||
messages = record._message_sms(
|
||||
body='Performance Test',
|
||||
partner_ids=pids,
|
||||
@@ -64,7 +64,7 @@ class TestSMSPerformance(BaseMailPerformance, sms_common.SMSCase):
|
||||
@warmup
|
||||
def test_message_sms_record_default(self):
|
||||
record = self.test_record.with_user(self.env.user)
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=28):
|
||||
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=25):
|
||||
messages = record._message_sms(
|
||||
body='Performance Test',
|
||||
)
|
||||
@@ -117,7 +117,7 @@ class TestSMSMassPerformance(BaseMailPerformance, sms_common.MockSMS):
|
||||
'mass_keep_log': False,
|
||||
})
|
||||
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=54):
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=55):
|
||||
composer.action_send_sms()
|
||||
|
||||
@mute_logger('odoo.addons.sms.models.sms_sms')
|
||||
@@ -133,5 +133,5 @@ class TestSMSMassPerformance(BaseMailPerformance, sms_common.MockSMS):
|
||||
'mass_keep_log': True,
|
||||
})
|
||||
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=57):
|
||||
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=58):
|
||||
composer.action_send_sms()
|
||||
|
||||
@@ -334,9 +334,9 @@ class TestSMSPostException(SMSCommon, TestSMSRecipients):
|
||||
messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids)
|
||||
|
||||
self.assertSMSNotification([
|
||||
{'partner': self.partner_1, 'state': 'sent'},
|
||||
{'partner': self.partner_1, 'state': 'pending'},
|
||||
{'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_credit'},
|
||||
{'partner': self.partner_3, 'state': 'sent'},
|
||||
{'partner': self.partner_3, 'state': 'pending'},
|
||||
], self._test_body, messages)
|
||||
|
||||
def test_message_sms_crash_server_crash(self):
|
||||
@@ -366,9 +366,9 @@ class TestSMSPostException(SMSCommon, TestSMSRecipients):
|
||||
messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids)
|
||||
|
||||
self.assertSMSNotification([
|
||||
{'partner': self.partner_1, 'state': 'sent'},
|
||||
{'partner': self.partner_1, 'state': 'pending'},
|
||||
{'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_acc'},
|
||||
{'partner': self.partner_3, 'state': 'sent'},
|
||||
{'partner': self.partner_3, 'state': 'pending'},
|
||||
], self._test_body, messages)
|
||||
|
||||
def test_message_sms_crash_wrong_number(self):
|
||||
@@ -387,9 +387,9 @@ class TestSMSPostException(SMSCommon, TestSMSRecipients):
|
||||
messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids)
|
||||
|
||||
self.assertSMSNotification([
|
||||
{'partner': self.partner_1, 'state': 'sent'},
|
||||
{'partner': self.partner_1, 'state': 'pending'},
|
||||
{'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_number_format'},
|
||||
{'partner': self.partner_3, 'state': 'sent'},
|
||||
{'partner': self.partner_3, 'state': 'pending'},
|
||||
], self._test_body, messages)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestSMSPost(SMSCommon, MockLinkTracker):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSMSPost, cls).setUpClass()
|
||||
super().setUpClass()
|
||||
cls._test_body = 'VOID CONTENT'
|
||||
|
||||
cls.sms_all = cls.env['sms.sms']
|
||||
@@ -55,51 +55,61 @@ class TestSMSPost(SMSCommon, MockLinkTracker):
|
||||
'+32456000022': 'credit',
|
||||
'+32456000033': 'server_error',
|
||||
'+32456000044': 'unregistered',
|
||||
}):
|
||||
}):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(raise_exception=False)
|
||||
remaining = self.sms_all.exists()
|
||||
self.assertEqual(len(remaining), 4)
|
||||
self.assertTrue(all(sms.state == 'error') for sms in remaining)
|
||||
self.assertEqual(set(remaining.mapped('state')), {'error'})
|
||||
|
||||
def test_sms_send_delete_failed(self):
|
||||
with self.mockSMSGateway(sms_allow_unlink=True, nbr_t_error={
|
||||
'+32456000011': 'wrong_number_format',
|
||||
'+32456000022': 'wrong_number_format',
|
||||
}):
|
||||
}):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(unlink_failed=True, unlink_sent=False, raise_exception=False)
|
||||
remaining = self.sms_all.exists()
|
||||
self.assertEqual(len(remaining), 8)
|
||||
self.assertTrue(all(sms.state == 'sent') for sms in remaining)
|
||||
self.assertEqual(set(remaining.mapped('state')), {'pending'})
|
||||
|
||||
def test_sms_send_delete_none(self):
|
||||
with self.mockSMSGateway(sms_allow_unlink=True, nbr_t_error={
|
||||
'+32456000011': 'wrong_number_format',
|
||||
'+32456000022': 'wrong_number_format',
|
||||
}):
|
||||
}):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(unlink_failed=False, unlink_sent=False, raise_exception=False)
|
||||
self.assertEqual(len(self.sms_all.exists()), 10)
|
||||
success_sms = self.sms_all[:1] + self.sms_all[3:]
|
||||
error_sms = self.sms_all[1:3]
|
||||
self.assertTrue(all(sms.state == 'sent') for sms in success_sms)
|
||||
self.assertTrue(all(sms.state == 'error') for sms in error_sms)
|
||||
self.assertEqual(set(success_sms.mapped('state')), {'pending'})
|
||||
self.assertEqual(set(error_sms.mapped('state')), {'error'})
|
||||
|
||||
def test_sms_send_delete_sent(self):
|
||||
with self.mockSMSGateway(sms_allow_unlink=True, nbr_t_error={
|
||||
'+32456000011': 'wrong_number_format',
|
||||
'+32456000022': 'wrong_number_format',
|
||||
}):
|
||||
}):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(unlink_failed=False, unlink_sent=True, raise_exception=False)
|
||||
remaining = self.sms_all.exists()
|
||||
self.assertEqual(len(remaining), 2)
|
||||
self.assertTrue(all(sms.state == 'error') for sms in remaining)
|
||||
self.assertEqual(set(remaining.mapped('state')), {'error'})
|
||||
|
||||
def test_sms_send_raise(self):
|
||||
with self.assertRaises(exceptions.AccessError):
|
||||
with self.mockSMSGateway(sim_error='jsonrpc_exception'):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(raise_exception=True)
|
||||
self.assertEqual(set(self.sms_all.mapped('state')), set(['outgoing']))
|
||||
self.assertEqual(set(self.sms_all.mapped('state')), {'outgoing'})
|
||||
|
||||
def test_sms_send_raise_catch(self):
|
||||
with self.mockSMSGateway(sim_error='jsonrpc_exception'):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(raise_exception=False)
|
||||
self.assertEqual(set(self.sms_all.mapped('state')), set(['error']))
|
||||
self.assertEqual(set(self.sms_all.mapped('state')), {'error'})
|
||||
|
||||
def test_sms_send_to_process(self):
|
||||
with self.mockSMSGateway(moderated=True):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send(raise_exception=False)
|
||||
self.assertEqual(set(self.sms_all.mapped('state')), {'process'})
|
||||
|
||||
def test_sms_send_to_unknown_error(self):
|
||||
with self.mockSMSGateway(sim_error='something_new'):
|
||||
self.env['sms.sms'].browse(self.sms_all.ids).send()
|
||||
self.assertEqual(set(self.sms_all.mapped('state')), {'error'})
|
||||
|
||||
@@ -13,3 +13,4 @@ from . import test_mailing_statistics
|
||||
from . import test_mailing_statistics_sms
|
||||
from . import test_mailing_test
|
||||
from . import test_performance
|
||||
from . import test_sms_controller
|
||||
|
||||
@@ -151,7 +151,7 @@ class TestMassSMSInternals(TestMassSMSCommon):
|
||||
@users('user_marketing')
|
||||
def test_mass_sms_internals_done_ids(self):
|
||||
mailing = self.env['mailing.mailing'].browse(self.mailing_sms.ids)
|
||||
mailing.write({'sms_force_send': False}) # check with outgoing traces, not already sent
|
||||
mailing.write({'sms_force_send': False}) # check with outgoing traces, not already pending
|
||||
|
||||
with self.with_user('user_marketing'):
|
||||
with self.mockSMSGateway():
|
||||
@@ -189,6 +189,71 @@ class TestMassSMSInternals(TestMassSMSCommon):
|
||||
mailing, self.records[5:],
|
||||
)
|
||||
|
||||
@users('user_marketing')
|
||||
def test_mass_sms_processing_with_force_send(self):
|
||||
"""Test that a `processing` status returned by IAP is immediately applied to traces and mailing stays sending.
|
||||
|
||||
The status update case where the sms are sent via the cron is done in TestSmsController.
|
||||
"""
|
||||
with self.with_user('user_marketing'):
|
||||
mailing = self.env['mailing.mailing'].create({
|
||||
'name': 'Xmas Spam',
|
||||
'subject': 'Xmas Spam',
|
||||
'mailing_model_id': self.env['ir.model']._get('mail.test.sms').id,
|
||||
'mailing_type': 'sms',
|
||||
'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]),
|
||||
'sms_template_id': self.sms_template.id,
|
||||
'sms_allow_unsubscribe': False,
|
||||
})
|
||||
|
||||
remaining_res_ids = mailing._get_remaining_recipients()
|
||||
self.assertEqual(set(remaining_res_ids), set(self.records.ids))
|
||||
|
||||
mailing.sms_force_send = True
|
||||
with self.mockSMSGateway(moderated=True):
|
||||
mailing.action_send_sms()
|
||||
|
||||
self.assertEqual(mailing.state, 'sending')
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': self.records_numbers[i],
|
||||
'content': 'Dear %s this is a mass SMS.' % record.display_name,
|
||||
'trace_status': 'process',
|
||||
} for i, record in enumerate(self.records)],
|
||||
mailing, self.records,
|
||||
)
|
||||
|
||||
@users('user_marketing')
|
||||
def test_mass_sms_error_with_force_send(self):
|
||||
"""Test that a failed status returned by IAP is immediately applied to traces."""
|
||||
with self.with_user('user_marketing'):
|
||||
mailing = self.env['mailing.mailing'].create({
|
||||
'name': 'Xmas Spam',
|
||||
'subject': 'Xmas Spam',
|
||||
'mailing_model_id': self.env['ir.model']._get('mail.test.sms').id,
|
||||
'mailing_type': 'sms',
|
||||
'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]),
|
||||
'sms_template_id': self.sms_template.id,
|
||||
'sms_allow_unsubscribe': False,
|
||||
})
|
||||
|
||||
remaining_res_ids = mailing._get_remaining_recipients()
|
||||
self.assertEqual(set(remaining_res_ids), set(self.records.ids))
|
||||
|
||||
mailing.sms_force_send = True
|
||||
with self.mockSMSGateway(sim_error='server_error'):
|
||||
mailing.action_send_sms()
|
||||
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': self.records_numbers[i],
|
||||
'content': 'Dear %s this is a mass SMS.' % record.display_name,
|
||||
'trace_status': 'error',
|
||||
'failure_type': 'sms_server'
|
||||
} for i, record in enumerate(self.records)],
|
||||
mailing, self.records,
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_render_mixin')
|
||||
def test_mass_sms_test_button(self):
|
||||
mailing = self.env['mailing.mailing'].create({
|
||||
@@ -238,7 +303,7 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': self.records_numbers[i],
|
||||
'trace_status': 'sent',
|
||||
'trace_status': 'pending',
|
||||
'content': 'Dear %s this is a mass SMS with two links' % record.display_name
|
||||
} for i, record in enumerate(self.records)],
|
||||
mailing, self.records,
|
||||
@@ -273,7 +338,7 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': record.customer_id.phone_sanitized,
|
||||
'trace_status': 'sent',
|
||||
'trace_status': 'pending',
|
||||
'content': 'Dear %s this is a mass SMS with two links' % record.display_name
|
||||
} for record in records],
|
||||
mailing, records,
|
||||
@@ -298,7 +363,7 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'partner': new_record.customer_id,
|
||||
'number': new_record.customer_id.phone_sanitized,
|
||||
'trace_status': 'sent',
|
||||
'trace_status': 'pending',
|
||||
'content': 'Dear %s this is a mass SMS with two links' % new_record.display_name
|
||||
}],
|
||||
mailing, new_record,
|
||||
@@ -353,8 +418,8 @@ class TestMassSMS(TestMassSMSCommon):
|
||||
self.assertSMSTraces(
|
||||
[{'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': '+32456000202', 'trace_status': 'pending'},
|
||||
{'number': '+32456000303', 'trace_status': 'pending'},
|
||||
{'number': '+32456000404', 'trace_status': 'cancel', 'failure_type': 'sms_blacklist'}],
|
||||
mailing, recipients
|
||||
)
|
||||
|
||||
@@ -35,14 +35,18 @@ class TestMailingStatistics(TestMassSMSCommon):
|
||||
with self.mockSMSGateway():
|
||||
mailing.action_send_sms()
|
||||
|
||||
# simulate some delivery reports
|
||||
for record_idx in range(4):
|
||||
self.gateway_sms_delivered(mailing, target_records[record_idx])
|
||||
|
||||
# simulate some replies and clicks
|
||||
self.gateway_sms_click(mailing, target_records[0])
|
||||
self.gateway_sms_click(mailing, target_records[2])
|
||||
self.gateway_sms_click(mailing, target_records[3])
|
||||
for record_idx in (0, 2, 3):
|
||||
self.gateway_sms_click(mailing, target_records[record_idx])
|
||||
|
||||
# check mailing statistics
|
||||
self.assertEqual(mailing.clicked, 3)
|
||||
self.assertEqual(mailing.delivered, 10)
|
||||
self.assertEqual(mailing.delivered, 4)
|
||||
self.assertEqual(mailing.received_ratio, 40)
|
||||
self.assertEqual(mailing.opened, 3)
|
||||
self.assertEqual(mailing.opened_ratio, 30)
|
||||
self.assertEqual(mailing.sent, 10)
|
||||
@@ -63,7 +67,7 @@ class TestMailingStatistics(TestMassSMSCommon):
|
||||
kpi_values = body_html.xpath('//table[@data-field="sms"]//*[hasclass("kpi_value")]/text()')
|
||||
self.assertEqual(
|
||||
[t.strip().strip('%') for t in kpi_values],
|
||||
['100.0', str(float(mailing.opened_ratio)), str(float(mailing.replied_ratio))]
|
||||
['40.0', str(float(mailing.opened_ratio)), str(float(mailing.replied_ratio))]
|
||||
)
|
||||
# test body content: clicks (a bit hackish but hey we are in stable)
|
||||
kpi_click_values = body_html.xpath('//table//tr[contains(@style,"color: #888888")]/td[contains(@style,"width: 30%")]/text()')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.test_mass_mailing.tests.common import TestMassSMSCommon
|
||||
from odoo.tests.common import HttpCase
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
class TestSmsController(HttpCase, TestMassSMSCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.recipients = cls._create_mailing_sms_test_records(model='mail.test.sms', count=5)
|
||||
cls.mailing_sms.mailing_domain = [('id', 'in', cls.recipients.ids)]
|
||||
|
||||
def _send_sms_immediately_and_assert_traces(self, moderated=False):
|
||||
self.mailing_sms.sms_force_send = True
|
||||
with self.mockSMSGateway(moderated=moderated):
|
||||
self.mailing_sms.action_send_sms()
|
||||
|
||||
all_traces = self.assertSMSTraces(
|
||||
[{'partner': record.customer_id,
|
||||
'number': '+32' + record.phone_nbr[1:],
|
||||
'trace_status': 'process' if moderated else 'pending',
|
||||
} for i, record in enumerate(self.recipients)],
|
||||
self.mailing_sms, self.recipients,
|
||||
)
|
||||
return all_traces
|
||||
|
||||
@mute_logger("odoo.addons.base.models.ir_http")
|
||||
def test_webhook_update_traces_pending_to_sent(self):
|
||||
all_traces = self._send_sms_immediately_and_assert_traces()
|
||||
first_two_traces = all_traces[:2]
|
||||
other_traces = all_traces[2:]
|
||||
statuses = [{'sms_status': 'delivered', 'uuids': first_two_traces.sms_tracker_ids.mapped('sms_uuid')}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(set(first_two_traces.mapped('trace_status')), {'sent'})
|
||||
self.assertEqual(set(other_traces.mapped('trace_status')), {'pending'})
|
||||
|
||||
@mute_logger("odoo.addons.base.models.ir_http")
|
||||
def test_webhook_update_traces_process_to_pending(self):
|
||||
self.assertEqual(self.mailing_sms.state, 'draft')
|
||||
all_traces = self._send_sms_immediately_and_assert_traces(moderated=True)
|
||||
self.assertEqual(self.mailing_sms.state, 'sending')
|
||||
statuses = [{'sms_status': 'sent', 'uuids': all_traces.sms_tracker_ids.mapped('sms_uuid')}]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(set(all_traces.mapped('trace_status')), {'pending'})
|
||||
self.assertEqual(self.mailing_sms.state, 'done')
|
||||
|
||||
@mute_logger("odoo.addons.base.models.ir_http")
|
||||
def test_webhook_update_traces_sent_to_bounce_and_failed(self):
|
||||
all_traces = self._send_sms_immediately_and_assert_traces()
|
||||
trace_1, trace_2 = all_traces[:2]
|
||||
other_traces = all_traces[2:]
|
||||
statuses = [
|
||||
{'sms_status': 'invalid_destination', 'uuids': [trace_1.sms_tracker_ids.sms_uuid]},
|
||||
{'sms_status': 'sms_not_delivered', 'uuids': [trace_2.sms_tracker_ids.sms_uuid]},
|
||||
{'sms_status': 'delivered', 'uuids': other_traces.sms_tracker_ids.mapped('sms_uuid')}
|
||||
]
|
||||
self.assertEqual(self._make_webhook_jsonrpc_request(statuses), 'OK')
|
||||
self.assertEqual(trace_1.trace_status, 'bounce')
|
||||
self.assertEqual(trace_2.trace_status, 'error')
|
||||
self.assertTrue(set(other_traces.mapped('trace_status')), {'sent'})
|
||||
Reference in New Issue
Block a user