[IMP] sms: allow to resend and cancel SMS notifications

Purpose of this commit is to allow SMS notification management. It is based
on what has been done in mail for notification: resend and cancel.

In this commit we add wizards allowing users to resend and cancel failed
SMS notifications. Resend allow to change the contact number, as well as
resending only a subset of notifications.

Future commits will improve the UX itself.

Related to task 1922163
Linked to PR #33510

Co-Authored-By: Thibault Delavallee <tde@odoo.com>
Co-Authored-By: Pierre Rousseau <pro@odoo.com>
This commit is contained in:
Thibault Delavallée
2019-07-12 14:54:11 +00:00
co-authored by Pierre Rousseau
parent 14ccc770bb
commit 45555bdd1f
11 changed files with 462 additions and 3 deletions
+2
View File
@@ -13,7 +13,9 @@ The service is provided by the In App Purchase Odoo platform.
'depends': ['base', 'iap', 'mail', 'phone_validation'],
'data': [
'data/ir_cron_data.xml',
'wizard/sms_cancel_views.xml',
'wizard/sms_composer_views.xml',
'wizard/sms_resend_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
'views/assets.xml',
+78 -1
View File
@@ -1,7 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from collections import defaultdict
from operator import itemgetter
from odoo import api, exceptions, fields, models
from odoo.tools import groupby
class MailMessage(models.Model):
@@ -11,3 +15,76 @@ class MailMessage(models.Model):
_inherit = 'mail.message'
message_type = fields.Selection(selection_add=[('sms', 'SMS')])
@api.multi
def _format_mail_failures(self):
""" A shorter message to notify a SMS delivery failure update
TDE FIXME: should be cleaned
"""
res = super(MailMessage, self)._format_mail_failures()
# prepare notifications computation in batch
all_notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', 'in', self.ids)
])
msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo())
for notif in all_notifications:
msgid_to_notif[notif.mail_message_id.id] += notif
for message in self:
notifications = msgid_to_notif[message.id]
if not any(notification.notification_type == 'sms' for notification in notifications):
continue
info = dict(message._get_mail_failure_dict(),
failure_type='sms',
notifications=dict((notif.res_partner_id.id, (notif.notification_status, notif.res_partner_id.name)) for notif in notifications if notif.notification_type == 'sms'),
module_icon='/sms/static/img/sms_failure.png'
)
res.append(info)
return res
@api.multi
def _notify_sms_update(self):
messages = self.env['mail.message']
for message in self:
# Check if user has access to the record before displaying a notification about it.
# In case the user switches from one company to another, it might happen that he doesn't
# have access to the record related to the notification. In this case, we skip it.
if message.model and message.res_id:
record = self.env[message.model].browse(message.res_id)
try:
record.check_access_rights('read')
record.check_access_rule('read')
except exceptions.AccessError:
continue
else:
messages |= message
""" Notify channels after update of SMS status """
updates = [[
(self._cr.dbname, 'res.partner', author.id),
{'type': 'sms_update', 'elements': self.env['mail.message'].concat(*author_messages)._format_mail_failures()}
] for author, author_messages in groupby(messages, itemgetter('author_id'))]
self.env['bus.bus'].sendmany(updates)
@api.multi
def message_format(self):
""" Override in order to retrieves data about SMS (recipient name and
SMS status)
TDE FIXME: clean the overall message_format thingy
"""
message_values = super(MailMessage, self).message_format()
all_sms_notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', 'in', [r['id'] for r in message_values]),
('notification_type', '=', 'sms')
])
msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo())
for notif in all_sms_notifications:
msgid_to_notif[notif.mail_message_id.id] += notif
for message in message_values:
customer_sms_data = [(notif.id, notif.res_partner_id.display_name, notif.notification_status) for notif in msgid_to_notif.get(message['id'], [])]
message['sms_ids'] = customer_sms_data
return message_values
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+2
View File
@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from . import sms_cancel
from . import sms_composer
from . import sms_resend
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class SMSCancel(models.TransientModel):
_name = 'sms.cancel'
_description = 'Dismiss notification for resend by model'
model = fields.Char(string='Model', required=True)
help_message = fields.Char(string='Help message', compute='_compute_help_message')
@api.multi
@api.depends('model')
def _compute_help_message(self):
for wizard in self:
wizard.help_message = _("Are you sure you want to discard %s SMS delivery failures. You won't be able to re-send these SMS later!") % (wizard._context.get('unread_counter'))
@api.multi
def action_cancel(self):
# TDE CHECK: delete pending SMS
author_id = self.env.user.partner_id.id
for wizard in self:
self._cr.execute("""
SELECT notif.id, msg.id
FROM mail_message_res_partner_needaction_rel notif
JOIN mail_message msg
ON notif.mail_message_id = msg.id
WHERE notif.notification_type = 'sms' IS TRUE AND notif.notification_status IN ('bounce', 'exception')
AND msg.model = %s
AND msg.author_id = %s """, (wizard.model, author_id))
res = self._cr.fetchall()
notif_ids = [row[0] for row in res]
message_ids = list(set([row[1] for row in res]))
if notif_ids:
self.env['mail.notification'].browse(notif_ids).sudo().write({'notification_status': 'canceled'})
if message_ids:
self.env['mail.message'].browse(message_ids)._notify_sms_update()
return {'type': 'ir.actions.act_window_close'}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="sms_cancel" model="ir.ui.view">
<field name="name">sms.cancel.form</field>
<field name="model">sms.cancel</field>
<field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
<field name="arch" type="xml">
<form string="Cancel notification in failure">
<field name="model" invisible='1'/>
<field name="help_message"/>
<p>If you want to re-send them, click Cancel now, then click on the notification and review them one by one by clicking on the red icon next to each message.</p>
<footer>
<button string="Discard delivery failures" name="cancel_resend_action" type="object" class="btn-primary" />
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="sms_cancel_action" model="ir.actions.act_window">
<field name="name">Discard SMS delivery failures</field>
<field name="res_model">sms.cancel</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data></odoo>
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, exceptions, fields, models
class SMSRecipient(models.TransientModel):
_name = 'sms.resend.recipient'
_description = 'Resend Notification'
_rec_name = 'sms_resend_id'
sms_resend_id = fields.Many2one('sms.resend', required=True)
notification_id = fields.Many2one('mail.notification', required=True, ondelete='cascade')
resend = fields.Boolean(string="Resend", default=True)
failure_type = fields.Selection([
('sms_number_missing', 'Missing Number'),
('sms_number_format', 'Wrong Number Format'),
('sms_credit', 'Insufficient Credit'),
('sms_server', 'Server Error')], related='notification_id.failure_type', related_sudo=True, readonly=True)
partner_id = fields.Many2one('res.partner', 'Partner', related='notification_id.res_partner_id', readonly=True)
partner_name = fields.Char('Recipient', readonly='True')
sms_number = fields.Char('Number')
class SMSResend(models.TransientModel):
_name = 'sms.resend'
_description = 'SMS Resend'
_rec_name = 'mail_message_id'
@api.model
def default_get(self, fields):
result = super(SMSResend, self).default_get(fields)
if result.get('mail_message_id'):
mail_message_id = self.env['mail.message'].browse(result['mail_message_id'])
result['recipient_ids'] = [(0, 0, {
'notification_id': notif.id,
'resend': True,
'failure_type': notif.failure_type,
'partner_name': notif.res_partner_id.display_name or mail_message_id.record_name,
'sms_number': notif.sms_number,
}) for notif in mail_message_id.notification_ids if notif.notification_type == 'sms' and notif.notification_status in ('exception', 'bounce')]
return result
mail_message_id = fields.Many2one('mail.message', 'Message', readonly=True, required=True)
recipient_ids = fields.One2many('sms.resend.recipient', 'sms_resend_id', string='Recipients')
has_cancel = fields.Boolean(compute='_compute_has_cancel')
has_insufficient_credit = fields.Boolean(compute='_compute_has_insufficient_credit')
@api.depends("recipient_ids.failure_type")
def _compute_has_insufficient_credit(self):
self.has_insufficient_credit = self.recipient_ids.filtered(lambda p: p.failure_type == 'sms_credit')
@api.depends("recipient_ids.resend")
def _compute_has_cancel(self):
self.has_cancel = self.recipient_ids.filtered(lambda p: not p.resend)
def _check_access(self):
if not self.mail_message_id or not self.mail_message_id.model or not self.mail_message_id.res_id:
raise exceptions.UserError(_('You do not have access to the message and/or related document.'))
record = self.env[self.mail_message_id.model].browse(self.mail_message_id.res_id)
record.check_access_rights('read')
record.check_access_rule('read')
@api.multi
def action_resend(self):
self._check_access()
all_notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', '=', self.mail_message_id.id),
('notification_type', '=', 'sms'),
('notification_status', 'in', ('exception', 'bounce'))
])
sudo_self = self.sudo()
to_cancel_ids = [r.notification_id.id for r in sudo_self.recipient_ids if not r.resend]
to_resend_ids = [r.notification_id.id for r in sudo_self.recipient_ids if r.resend]
if to_cancel_ids:
all_notifications.filtered(lambda n: n.id in to_cancel_ids).write({'notification_status': 'canceled'})
if to_resend_ids:
record = self.env[self.mail_message_id.model].browse(self.mail_message_id.res_id)
sms_pid_to_number = dict((r.partner_id.id, r.sms_number) for r in self.recipient_ids if r.resend and r.partner_id)
pids = list(sms_pid_to_number.keys())
numbers = [r.sms_number for r in self.recipient_ids if r.resend and not r.partner_id]
rdata = []
for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(record, 'sms', False, pids=pids):
if pid and notif == 'sms':
rdata.append({'id': pid, 'share': pshare, 'active': active, 'notif': notif, 'groups': groups or [], 'type': 'customer' if pshare else 'user'})
if rdata or numbers:
record._notify_record_by_sms(
self.mail_message_id, {'partners': rdata}, check_existing=True,
sms_numbers=numbers, sms_pid_to_number=sms_pid_to_number,
put_in_queue=False
)
self.mail_message_id._notify_sms_update()
return {'type': 'ir.actions.act_window_close'}
@api.multi
def action_cancel(self):
self._check_access()
sudo_self = self.sudo()
sudo_self.mapped('recipient_ids.notification_id').write({'notification_status': 'canceled'})
self.mail_message_id._notify_sms_update()
return {'type': 'ir.actions.act_window_close'}
@api.multi
def action_buy_credits(self):
url = self.env['iap.account'].get_credits_url(service_name='sms')
return {
'type': 'ir.actions.act_url',
'url': url,
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="mail_resend_message_view_form" model="ir.ui.view">
<field name="name">sms.resend.form</field>
<field name="model">sms.resend</field>
<field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
<field name="arch" type="xml">
<form string="Edit Partners">
<field name="mail_message_id" invisible="1"/>
<field name="has_cancel" invisible="1"/>
<field name="has_insufficient_credit" invisible="1"/>
<field name="recipient_ids">
<tree string="Recipient" editable="top" create="0" delete="0">
<field name="partner_name"/>
<field name="sms_number"/>
<field name="failure_type" string="Reason"/>
<field name="resend" widget="boolean_toggle"/>
</tree>
</field>
<div class="alert alert-warning" role="alert" attrs="{'invisible': [('has_cancel', '=', False)]}">
<span class="fa fa-info-circle"/> Caution: It won't be possible to send this SMS again to the recipients you did not select.
</div>
<footer>
<button string="Buy credits" name="action_buy_credits" type="object" class="btn-primary o_mail_send"
attrs="{'invisible': [('has_insufficient_credit', '=', False)]}"/>
<button string="Resend" name="action_resend" type="object" class="btn-primary o_mail_send"/>
<button string="Ignore all" name="action_cancel" type="object" class="btn-secondary" />
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="sms_resend_action" model="ir.actions.act_window">
<field name="name">Sending Failures</field>
<field name="res_model">sms.resend</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data></odoo>
+2 -2
View File
@@ -209,7 +209,7 @@ class TestAdvMailPerformance(BaseMailPerformance):
@warmup
def test_message_assignation_inbox(self):
record = self.env['mail.test.track'].create({'name': 'Test'})
with self.assertQueryCount(__system__=31, emp=36): # test_mail only: 31 - 36
with self.assertQueryCount(__system__=32, emp=37): # test_mail only: 32 - 37
record.write({
'user_id': self.user_test.id,
})
@@ -265,7 +265,7 @@ class TestAdvMailPerformance(BaseMailPerformance):
def test_message_post_one_inbox_notification(self):
record = self.env['mail.test.simple'].create({'name': 'Test'})
with self.assertQueryCount(__system__=28, emp=33): # com runbot 28 - 33 // test_mail only: 28 - 33
with self.assertQueryCount(__system__=29, emp=34): # com runbot 29 - 34 // test_mail only: 29 - 34
record.message_post(
body='<p>Test Post Performances with an inbox ping</p>',
partner_ids=self.user_test.partner_id.ids,
+1
View File
@@ -2,6 +2,7 @@
from . import common
from . import test_sms_composer
from . import test_sms_management
from . import test_sms_performance
from . import test_sms_post
from . import test_sms_sms
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestSMSWizards(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
@classmethod
def setUpClass(cls):
super(TestSMSWizards, cls).setUpClass()
cls.test_record = cls.env['mail.test.sms'].with_context(**cls._test_context).create({
'name': 'Test',
'customer_id': cls.partner_1.id,
})
cls.test_record = cls._reset_mail_context(cls.test_record)
cls.msg = cls.test_record.message_post(body='TEST BODY', author_id=cls.partner_employee.id)
cls.notif_p1 = cls.env['mail.notification'].create({
'mail_message_id': cls.msg.id,
'res_partner_id': cls.partner_1.id,
'sms_number': cls.partner_1.mobile,
'notification_type': 'sms',
'notification_status': 'exception',
'failure_type': 'sms_number_format',
})
cls.notif_p2 = cls.env['mail.notification'].create({
'mail_message_id': cls.msg.id,
'res_partner_id': cls.partner_2.id,
'sms_number': cls.partner_2.mobile,
'notification_type': 'sms',
'notification_status': 'exception',
'failure_type': 'sms_credit',
})
def test_sms_resend(self):
self._clear_bus()
with self.sudo('employee'):
wizard = self.env['sms.resend'].with_context(default_mail_message_id=self.msg.id).create({})
wizard.write({'recipient_ids': [(1, r.id, {'resend': True}) for r in wizard.recipient_ids]})
with self.mockSMSGateway():
wizard.action_resend()
self.assertSMSNotification([
{'partner': self.partner_1, 'state': 'sent'},
{'partner': self.partner_2, 'state': 'sent'}
], 'TEST BODY', self.msg, check_sms=True)
self.assertBusNotification(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{'type': 'sms_update', 'elements': [{
'message_id': self.msg.id,
'failure_type': 'sms',
'notifications': {'%s' % self.partner_1.id: ['sent', self.partner_1.name], '%s' % self.partner_2.id: ['sent', self.partner_2.name]}
}]}]
)
def test_sms_resend_update_number(self):
self._clear_bus()
with self.sudo('employee'):
wizard = self.env['sms.resend'].with_context(default_mail_message_id=self.msg.id).create({})
wizard.write({'recipient_ids': [(1, r.id, {'resend': True, 'sms_number': self.random_numbers[idx]}) for idx, r in enumerate(wizard.recipient_ids)]})
with self.mockSMSGateway():
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]}
], 'TEST BODY', self.msg, check_sms=True)
self.assertBusNotification(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{'type': 'sms_update', 'elements': [{
'message_id': self.msg.id,
'failure_type': 'sms',
'notifications': {'%s' % self.partner_1.id: ['sent', self.partner_1.name], '%s' % self.partner_2.id: ['sent', self.partner_2.name]}
}]}]
)
def test_sms_resend_cancel(self):
self._clear_bus()
with self.sudo('employee'):
wizard = self.env['sms.resend'].with_context(default_mail_message_id=self.msg.id).create({})
with self.mockSMSGateway():
wizard.action_cancel()
self.assertSMSNotification([
{'partner': self.partner_1, 'state': 'canceled', 'number': self.notif_p1.sms_number, 'failure_type': 'sms_number_format'},
{'partner': self.partner_2, 'state': 'canceled', 'number': self.notif_p2.sms_number, 'failure_type': 'sms_credit'}
], 'TEST BODY', self.msg, check_sms=False)
self.assertBusNotification(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{'type': 'sms_update', 'elements': [{
'message_id': self.msg.id,
'failure_type': 'sms',
'notifications': {'%s' % self.partner_1.id: ['canceled', self.partner_1.name], '%s' % self.partner_2.id: ['canceled', self.partner_2.name]}
}]}]
)
def test_sms_resend_internals(self):
self._clear_bus()
self.assertSMSNotification([
{'partner': self.partner_1, 'state': 'exception', 'number': self.notif_p1.sms_number, 'failure_type': 'sms_number_format'},
{'partner': self.partner_2, 'state': 'exception', 'number': self.notif_p2.sms_number, 'failure_type': 'sms_credit'}
], 'TEST BODY', self.msg, check_sms=False)
with self.sudo('employee'):
wizard = self.env['sms.resend'].with_context(default_mail_message_id=self.msg.id).create({})
self.assertTrue(wizard.has_insufficient_credit)
self.assertEqual(set(wizard.mapped('recipient_ids.partner_name')), set((self.partner_1 | self.partner_2).mapped('display_name')))
wizard.write({'recipient_ids': [(1, r.id, {'resend': True}) for r in wizard.recipient_ids]})
with self.mockSMSGateway():
wizard.action_resend()
def test_sms_resend_w_cancel(self):
self._clear_bus()
with self.sudo('employee'):
wizard = self.env['sms.resend'].with_context(default_mail_message_id=self.msg.id).create({})
wizard.write({'recipient_ids': [(1, r.id, {'resend': True if r.partner_id == self.partner_1 else False}) for r in wizard.recipient_ids]})
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_2, 'state': 'canceled', 'number': self.notif_p2.sms_number, 'failure_type': 'sms_credit'}], 'TEST BODY', self.msg, check_sms=False)
self.assertBusNotification(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{'type': 'sms_update', 'elements': [{
'message_id': self.msg.id,
'failure_type': 'sms',
'notifications': {'%s' % self.partner_1.id: ['sent', self.partner_1.name], '%s' % self.partner_2.id: ['canceled', self.partner_2.name]}
}]}]
)
def test_sms_cancel(self):
self._clear_bus()
with self.mockSMSGateway(), self.sudo('employee'):
wizard = self.env['sms.cancel'].with_context(default_model=self.msg.model).create({})
wizard.action_cancel()
self.assertEqual((self.notif_p1 | self.notif_p2).mapped('notification_status'), ['canceled', 'canceled'])
self.assertBusNotification(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{'type': 'sms_update', 'elements': [{
'message_id': self.msg.id,
'failure_type': 'sms',
'notifications': {'%s' % self.partner_1.id: ['canceled', self.partner_1.name], '%s' % self.partner_2.id: ['canceled', self.partner_2.name]}
}]}]
)