[MERGE] mail, sms, test_mail(_full): prepare SMS refactoring
Purpose of this merge is to prepare upcoming SMS refactoring by
already pushing some tools, cleaning and tests in master.
Contained in this merge
* improvement of phonenumbers library tools and usage in SMS;
* addition of SMS test classes with tools and mocks;
* addition of SMS tests;
See sub commits for more details. Related to task 1922163.
closes odoo/odoo#34516
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
<act_window id="sms_message_send_action_mutli"
|
||||
name="Send SMS to attendees"
|
||||
binding_model="calendar.event"
|
||||
res_model="sms.send_sms"
|
||||
res_model="sms.composer"
|
||||
view_mode="form"
|
||||
target="new"/>
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ Please, take appropriate measures in order to carry out this work absence.
|
||||
Do not hesitate to contact your manager or the human resource department.""")
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "sms.send_sms",
|
||||
"res_model": "sms.composer",
|
||||
"view_mode": 'form',
|
||||
"context": {
|
||||
'active_id': self.id,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'data': [
|
||||
'wizard/invite_view.xml',
|
||||
'wizard/mail_compose_message_view.xml',
|
||||
'wizard/mail_resend_cancel_views.xml',
|
||||
'wizard/mail_resend_message_views.xml',
|
||||
'views/mail_message_subtype_views.xml',
|
||||
'views/mail_tracking_views.xml',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from . import invite
|
||||
from . import mail_compose_message
|
||||
from . import mail_resend_cancel
|
||||
from . import mail_resend_message
|
||||
from . import email_template_preview
|
||||
from . import base_module_uninstall
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class MailCancelResend(models.TransientModel):
|
||||
_name = 'mail.resend.cancel'
|
||||
_description = 'Dismiss notification for resend by model'
|
||||
|
||||
model = fields.Char(string='Model')
|
||||
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 mail delivery failures. You won't be able to re-send these mails later!") % (wizard._context.get('unread_counter'))
|
||||
|
||||
@api.multi
|
||||
def cancel_resend_action(self):
|
||||
author_id = self.env.user.partner_id.id
|
||||
for wizard in self:
|
||||
self._cr.execute("""
|
||||
SELECT notif.id, mes.id
|
||||
FROM mail_message_res_partner_needaction_rel notif
|
||||
JOIN mail_message mes
|
||||
ON notif.mail_message_id = mes.id
|
||||
WHERE notif.email_status IN ('bounce', 'exception')
|
||||
AND mes.model = %s
|
||||
AND mes.author_id = %s
|
||||
""", (wizard.model, author_id))
|
||||
res = self._cr.fetchall()
|
||||
notif_ids = [row[0] for row in res]
|
||||
messages_ids = list(set([row[1] for row in res]))
|
||||
if notif_ids:
|
||||
self.env["mail.notification"].browse(notif_ids).sudo().write({'email_status': 'canceled'})
|
||||
self.env["mail.message"].browse(messages_ids)._notify_failure_update()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo><data>
|
||||
<record id="mail_resend_cancel_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.resend.cancel.view.form</field>
|
||||
<field name="model">mail.resend.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 envelope next to each message.</p>
|
||||
<img src="/mail/static/img/red_envelope.png" alt="Envelope Example"/>
|
||||
<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="mail_resend_cancel_action" model="ir.actions.act_window">
|
||||
<field name="name">Discard mail delivery failures</field>
|
||||
<field name="res_model">mail.resend.cancel</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</data></odoo>
|
||||
@@ -1,10 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from ast import literal_eval
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class MailResendMessage(models.TransientModel):
|
||||
_name = 'mail.resend.message'
|
||||
_description = 'Email resend wizard'
|
||||
@@ -100,38 +100,3 @@ class PartnerResend(models.TransientModel):
|
||||
resend = fields.Boolean(string="Send Again", default=True)
|
||||
resend_wizard_id = fields.Many2one('mail.resend.message', string="Resend wizard")
|
||||
message = fields.Char(string="Help message")
|
||||
|
||||
|
||||
class MailCancelResend(models.TransientModel):
|
||||
_name = 'mail.resend.cancel'
|
||||
_description = 'Dismiss notification for resend by model'
|
||||
|
||||
model = fields.Char(string='Model')
|
||||
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 mail delivery failures. You won't be able to re-send these mails later!") % (wizard._context.get('unread_counter'))
|
||||
|
||||
@api.multi
|
||||
def cancel_resend_action(self):
|
||||
author_id = self.env.user.partner_id.id
|
||||
for wizard in self:
|
||||
self._cr.execute("""
|
||||
SELECT notif.id, mes.id
|
||||
FROM mail_message_res_partner_needaction_rel notif
|
||||
JOIN mail_message mes
|
||||
ON notif.mail_message_id = mes.id
|
||||
WHERE notif.email_status IN ('bounce', 'exception')
|
||||
AND mes.model = %s
|
||||
AND mes.author_id = %s
|
||||
""", (wizard.model, author_id))
|
||||
res = self._cr.fetchall()
|
||||
notif_ids = [row[0] for row in res]
|
||||
messages_ids = list(set([row[1] for row in res]))
|
||||
if notif_ids:
|
||||
self.env["mail.notification"].browse(notif_ids).sudo().write({'email_status': 'canceled'})
|
||||
self.env["mail.message"].browse(messages_ids)._notify_failure_update()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="mail_resend_message_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.resend.message.form</field>
|
||||
<field name="name">mail.resend.message.view.form</field>
|
||||
<field name="model">mail.resend.message</field>
|
||||
<field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
|
||||
<field name="arch" type="xml">
|
||||
@@ -31,23 +31,6 @@
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="mail_resend_cancel_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.resend.cancel.form</field>
|
||||
<field name="model">mail.resend.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 envelope next to each message.</p>
|
||||
<img src="/mail/static/img/red_envelope.png" alt="Envelope Example"/>
|
||||
<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="mail_resend_message_action" model="ir.actions.act_window">
|
||||
<field name="name">Resend mail</field>
|
||||
<field name="res_model">mail.resend.message</field>
|
||||
@@ -55,13 +38,5 @@
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<record id="mail_resend_cancel_action" model="ir.actions.act_window">
|
||||
<field name="name">Discard mail delivery failures</field>
|
||||
<field name="res_model">mail.resend.cancel</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -28,6 +28,6 @@ class PhoneValidationMixin(models.AbstractModel):
|
||||
number,
|
||||
country.code if country else None,
|
||||
country.phone_code if country else None,
|
||||
always_international=always_international,
|
||||
force_format='INTERNATIONAL' if always_international else 'NATIONAL',
|
||||
raise_exception=False
|
||||
)
|
||||
|
||||
@@ -26,14 +26,21 @@ try:
|
||||
|
||||
return phone_nbr
|
||||
|
||||
def phone_format(number, country_code, country_phone_code, always_international=True, raise_exception=True):
|
||||
def phone_format(number, country_code, country_phone_code, force_format='INTERNATIONAL', raise_exception=True):
|
||||
""" Format the given phone number according to the localisation and international options.
|
||||
:param number: number to convert
|
||||
:param country_code: the ISO country code in two chars
|
||||
:type country_code: str
|
||||
:param country_phone_code: country dial in codes, defined by the ITU-T (Ex: 32 for Belgium)
|
||||
:type country_phone_code: int
|
||||
:rtype: str
|
||||
:param number: number to convert
|
||||
:param country_code: the ISO country code in two chars
|
||||
:type country_code: str
|
||||
:param country_phone_code: country dial in codes, defined by the ITU-T (Ex: 32 for Belgium)
|
||||
:type country_phone_code: int
|
||||
:param force_format: stringified version of format globals (see
|
||||
https://github.com/daviddrysdale/python-phonenumbers/blob/dev/python/phonenumbers/phonenumberutil.py)
|
||||
'E164' = 0
|
||||
'INTERNATIONAL' = 1
|
||||
'NATIONAL' = 2
|
||||
'RFC3966' = 3
|
||||
:type force_format: str
|
||||
:rtype: str
|
||||
"""
|
||||
try:
|
||||
phone_nbr = phone_parse(number, country_code)
|
||||
@@ -43,7 +50,11 @@ try:
|
||||
else:
|
||||
_logger.warning(_('Unable to format %s:\n%s'), number, e)
|
||||
return number
|
||||
if always_international or phone_nbr.country_code != country_phone_code:
|
||||
if force_format == 'E164':
|
||||
phone_fmt = phonenumbers.PhoneNumberFormat.E164
|
||||
elif force_format == 'RFC3966':
|
||||
phone_fmt = phonenumbers.PhoneNumberFormat.RFC3966
|
||||
elif force_format == 'INTERNATIONAL' or phone_nbr.country_code != country_phone_code:
|
||||
phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
|
||||
else:
|
||||
phone_fmt = phonenumbers.PhoneNumberFormat.NATIONAL
|
||||
@@ -54,7 +65,7 @@ except ImportError:
|
||||
def phone_parse(number, country_code):
|
||||
return False
|
||||
|
||||
def phone_format(number, country_code, country_phone_code, always_international=True, raise_exception=True):
|
||||
def phone_format(number, country_code, country_phone_code, force_format='INTERNATIONAL', raise_exception=True):
|
||||
global _phonenumbers_lib_warning
|
||||
if not _phonenumbers_lib_warning:
|
||||
_logger.warning(
|
||||
@@ -63,3 +74,56 @@ except ImportError:
|
||||
)
|
||||
_phonenumbers_lib_warning = True
|
||||
return number
|
||||
|
||||
|
||||
def phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format='E164'):
|
||||
valid, invalid, void_count = [], [], 0
|
||||
for number in numbers:
|
||||
if not number:
|
||||
void_count += 1
|
||||
continue
|
||||
try:
|
||||
sanitized = phone_format(
|
||||
number, country_code, country_phone_code,
|
||||
force_format=force_format, raise_exception=True)
|
||||
except Exception as e:
|
||||
invalid.append(number)
|
||||
else:
|
||||
valid.append(sanitized)
|
||||
return valid, invalid, void_count
|
||||
|
||||
|
||||
def phone_sanitize_numbers_w_record(numbers, country_code, country_phone_code, record, record_country_fname='country_id', force_format='E164'):
|
||||
if not country_code or not country_phone_code:
|
||||
country = False
|
||||
if record and record_country_fname in record and record[record_country_fname]:
|
||||
country = record[record_country_fname]
|
||||
elif record:
|
||||
country = record.env.company.country_id
|
||||
if country:
|
||||
country_code = country_code if country_code else country.code
|
||||
country_phone_code = country_phone_code if country_phone_code else country.phone_code
|
||||
return phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format=force_format)
|
||||
|
||||
|
||||
def phone_sanitize_numbers_string_w_record(numbers_str, country_code, country_phone_code, record, record_country_fname='country_id', force_format='E164'):
|
||||
found_numbers = [number.strip() for number in numbers_str.split(',')]
|
||||
return phone_sanitize_numbers_w_record(found_numbers, country_code, country_phone_code, record, record_country_fname, force_format=force_format)
|
||||
|
||||
|
||||
def phone_get_sanitized_records_number(records, number_fname='mobile', country_fname='country_id', force_format='E164'):
|
||||
res = dict.fromkeys(records.ids, False)
|
||||
for record in records:
|
||||
number = record[number_fname]
|
||||
valid, invalid, void_count = phone_sanitize_numbers_w_record([number], None, None, records, country_fname,force_format=force_format)
|
||||
if valid:
|
||||
res[record.id] = valid[0]
|
||||
elif void_count:
|
||||
res[record.id] = False
|
||||
else:
|
||||
res[record.id] = False
|
||||
return res
|
||||
|
||||
|
||||
def phone_get_sanitized_record_number(record, number_fname='mobile', country_fname='country_id', force_format='E164'):
|
||||
return phone_get_sanitized_records_number(record, number_fname, country_fname, force_format=force_format)[record.id]
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import tests
|
||||
from . import wizard
|
||||
|
||||
@@ -10,12 +10,12 @@ This module gives a framework for SMS text messaging
|
||||
|
||||
The service is provided by the In App Purchase Odoo platform.
|
||||
""",
|
||||
'depends': ['base', 'iap', 'mail'],
|
||||
'depends': ['base', 'iap', 'mail', 'phone_validation'],
|
||||
'data': [
|
||||
'wizard/send_sms_views.xml',
|
||||
'wizard/sms_composer_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/templates.xml',
|
||||
'views/assets.xml',
|
||||
],
|
||||
'qweb': [
|
||||
'static/src/xml/sms_widget.xml',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo import api, models
|
||||
from odoo.addons.iap.models import iap
|
||||
|
||||
DEFAULT_ENDPOINT = 'https://iap-sms.odoo.com'
|
||||
@@ -13,15 +12,47 @@ class SmsApi(models.AbstractModel):
|
||||
_description = 'SMS API'
|
||||
|
||||
@api.model
|
||||
def _send_sms(self, numbers, message):
|
||||
""" Send sms
|
||||
"""
|
||||
def _contact_iap(self, local_endpoint, params):
|
||||
account = self.env['iap.account'].get('sms')
|
||||
params['account_token'] = account.account_token
|
||||
endpoint = self.env['ir.config_parameter'].sudo().get_param('sms.endpoint', DEFAULT_ENDPOINT)
|
||||
# TODO PRO, the default timeout is 15, do we have to increase it ?
|
||||
return 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 = {
|
||||
'account_token': account.account_token,
|
||||
'numbers': numbers,
|
||||
'message': message,
|
||||
}
|
||||
endpoint = self.env['ir.config_parameter'].sudo().get_param('sms.endpoint', DEFAULT_ENDPOINT)
|
||||
r = iap.jsonrpc(endpoint + '/iap/message_send', params=params)
|
||||
return True
|
||||
return self._contact_iap('/iap/message_send', params)
|
||||
|
||||
@api.model
|
||||
def _send_sms_batch(self, messages):
|
||||
""" 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_format_number' or 'success',
|
||||
'credit': integer: number of credits spent to send this SMS,
|
||||
}]
|
||||
|
||||
:raises: normally none
|
||||
"""
|
||||
params = {
|
||||
'messages': messages
|
||||
}
|
||||
return self._contact_iap('/iap/sms/1/send', params)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo import exceptions
|
||||
from odoo.tests import common
|
||||
from odoo.addons.sms.models.sms_api import SmsApi
|
||||
|
||||
|
||||
class MockSMS(common.BaseCase):
|
||||
|
||||
def tearDown(self):
|
||||
super(MockSMS, self).tearDown()
|
||||
self._clear_sms_sent
|
||||
|
||||
@contextmanager
|
||||
def mockSMSGateway(self, sim_error=None, nbr_t_error=None):
|
||||
self._sms = []
|
||||
|
||||
def _contact_iap(local_endpoint, params):
|
||||
# mock single sms sending
|
||||
if local_endpoint == '/iap/message_send':
|
||||
self._sms += [{
|
||||
'number': number,
|
||||
'body': params['message'],
|
||||
} for number in params['numbers']]
|
||||
return True # send_message v0 API returns always True
|
||||
# mock batch sending
|
||||
if local_endpoint == '/iap/sms/1/send':
|
||||
result = []
|
||||
for to_send in params['messages']:
|
||||
res = {'res_id': to_send['res_id'], 'state': 'success', 'credit': 1}
|
||||
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_format_number':
|
||||
res.update(state='wrong_format_number')
|
||||
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
|
||||
)
|
||||
result.append(res)
|
||||
if res['state'] == 'success':
|
||||
self._sms.append({
|
||||
'number': to_send['number'],
|
||||
'body': to_send['content'],
|
||||
})
|
||||
return result
|
||||
|
||||
try:
|
||||
with patch.object(SmsApi, '_contact_iap', side_effect=_contact_iap) as contact_iap_mock:
|
||||
yield
|
||||
finally:
|
||||
pass
|
||||
|
||||
def assertSMSSent(self, numbers, content):
|
||||
""" Check sent SMS. Order is not checked. Each number should have received
|
||||
the same content. Usefull to check batch sending.
|
||||
|
||||
:param numbers: list of numbers;
|
||||
:param content: content to check for each number;
|
||||
"""
|
||||
self.assertEqual(len(self._sms), len(numbers))
|
||||
for number in numbers:
|
||||
sent_sms = next((sms for sms in self._sms if sms['number'] == number), None)
|
||||
self.assertTrue(bool(sent_sms), 'Number %s not found in %s' % (number, repr([s['number'] for s in self._sms])))
|
||||
self.assertEqual(sent_sms['body'], content)
|
||||
|
||||
def _clear_sms_sent(self):
|
||||
self._sms = []
|
||||
@@ -2,8 +2,8 @@
|
||||
<odoo>
|
||||
|
||||
<!-- Add action entry in the Action Menu for Partners -->
|
||||
<record id="partner_form_send_sms_form_view" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.send.sms</field>
|
||||
<record id="res_partner_view_form" model="ir.ui.view">
|
||||
<field name="name">res.partner.view.form.inherit.sms</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="priority">10</field>
|
||||
@@ -14,7 +14,7 @@
|
||||
<field name="phone" widget="phone"/>
|
||||
<button
|
||||
type="action"
|
||||
name="%(sms.send_sms_form_action)d"
|
||||
name="%(sms.sms_composer_action_form)d"
|
||||
class="btn-sm btn-link mb4 fa fa-envelope-o"
|
||||
aria-label="Send SMS"
|
||||
title="Send SMS"
|
||||
@@ -29,7 +29,7 @@
|
||||
<field name="mobile" widget="phone"/>
|
||||
<button
|
||||
type="action"
|
||||
name="%(sms.send_sms_form_action)d"
|
||||
name="%(sms.sms_composer_action_form)d"
|
||||
class="btn-sm btn-link mb4 fa fa-envelope-o"
|
||||
aria-label="Send SMS"
|
||||
title="Send SMS"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import send_sms
|
||||
from . import sms_composer
|
||||
|
||||
@@ -23,7 +23,7 @@ except ImportError:
|
||||
|
||||
|
||||
class SendSMS(models.TransientModel):
|
||||
_name = 'sms.send_sms'
|
||||
_name = 'sms.composer'
|
||||
_description = 'Send SMS'
|
||||
|
||||
recipients = fields.Char('Recipients', required=True)
|
||||
@@ -1,9 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="send_sms_view_form" model="ir.ui.view">
|
||||
<field name="name">sms_send_sms.form</field>
|
||||
<field name="model">sms.send_sms</field>
|
||||
<record id="sms_composer_view_form" model="ir.ui.view">
|
||||
<field name="name">sms.composer.view.form</field>
|
||||
<field name="model">sms.composer</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Send an SMS">
|
||||
<sheet>
|
||||
@@ -23,18 +22,18 @@
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="send_sms_form_action" model="ir.actions.act_window">
|
||||
<record id="sms_composer_action_form" model="ir.actions.act_window">
|
||||
<field name="name">Send SMS</field>
|
||||
<field name="res_model">sms.send_sms</field>
|
||||
<field name="res_model">sms.composer</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<!-- Add action entry in the Action Menu for Partners -->
|
||||
<act_window id="send_sms_action"
|
||||
<act_window id="sms_composer_act_window"
|
||||
name="Send SMS"
|
||||
binding_model="res.partner"
|
||||
res_model="sms.send_sms"
|
||||
res_model="sms.composer"
|
||||
view_mode="form"
|
||||
target="new"/>
|
||||
|
||||
@@ -37,6 +37,17 @@ class BaseFunctionalTest(common.SavepointCase):
|
||||
|
||||
cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'})
|
||||
|
||||
@classmethod
|
||||
def _reset_mail_context(cls, record):
|
||||
return record.with_context(
|
||||
mail_create_nolog=False,
|
||||
mail_create_nosubscribe=False,
|
||||
mail_notrack=False
|
||||
)
|
||||
|
||||
def _clear_bus(self):
|
||||
self.env['bus.bus'].search([]).unlink()
|
||||
|
||||
@contextmanager
|
||||
def assertNotifications(self, **counters):
|
||||
""" Counters: 'partner_attribute': 'inbox' or 'email' """
|
||||
@@ -83,41 +94,57 @@ class BaseFunctionalTest(common.SavepointCase):
|
||||
if hasattr(self, 'assertEmails') and len(new_messages) == 1:
|
||||
self.assertEmails(new_messages.author_id, new_notifications.filtered(lambda n: n.is_email).mapped('res_partner_id'))
|
||||
|
||||
def assertBusNotification(self, channels, message_dicts=None, init=True):
|
||||
def assertBusNotification(self, channels, message_items=None, init=True):
|
||||
""" Check for bus notifications. Basic check is about used channels.
|
||||
Verifying content is optional.
|
||||
|
||||
:param channels: list of channel
|
||||
:param messages: if given, list of message making a valid pair (channel,
|
||||
:param message_items: if given, list of message making a valid pair (channel,
|
||||
message) to be found in bus.bus
|
||||
"""
|
||||
def check_content(returned_value, expected_value):
|
||||
if isinstance(expected_value, list):
|
||||
done = []
|
||||
for expected_item in expected_value:
|
||||
for returned_item in returned_value:
|
||||
if check_content(returned_item, expected_item):
|
||||
done.append(expected_item)
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return len(done) == len(expected_value)
|
||||
elif isinstance(expected_value, dict):
|
||||
return all(k in returned_value for k in expected_value.keys()) and all(
|
||||
check_content(returned_value[key], val)
|
||||
for key, val in expected_value.items()
|
||||
)
|
||||
else:
|
||||
return returned_value == expected_value
|
||||
|
||||
if init:
|
||||
self.assertEqual(len(self.env['bus.bus'].search([])), len(channels))
|
||||
notifications = self.env['bus.bus'].search([('channel', 'in', [json_dump(channel) for channel in channels])])
|
||||
notif_messages = [json.loads(n.message) for n in notifications]
|
||||
self.assertEqual(len(notifications), len(channels))
|
||||
if message_dicts:
|
||||
notif_messages = [json.loads(n.message) for n in notifications]
|
||||
for expected in message_dicts:
|
||||
found = False
|
||||
for returned in notif_messages:
|
||||
for key, val in expected.items():
|
||||
if key not in returned:
|
||||
continue
|
||||
if isinstance(returned[key], list):
|
||||
if set(returned[key]) != set(val):
|
||||
continue
|
||||
else:
|
||||
if returned[key] != val:
|
||||
continue
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
raise AssertionError("Bus notification content %s not found" % (repr(expected)))
|
||||
|
||||
for expected in message_items or []:
|
||||
for notification in notif_messages:
|
||||
found_keys, not_found_keys = [], []
|
||||
if not all(k in notification for k in expected.keys()):
|
||||
continue
|
||||
for expected_key, expected_value in expected.items():
|
||||
done = check_content(notification[expected_key], expected_value)
|
||||
if done:
|
||||
found_keys.append(expected_key)
|
||||
else:
|
||||
not_found_keys.append(expected_key)
|
||||
if set(found_keys) == set(expected.keys()):
|
||||
break
|
||||
else:
|
||||
raise AssertionError('Keys %s not found (expected: %s - returned: %s)' % (not_found_keys, repr(expected), repr(notif_messages)))
|
||||
|
||||
@contextmanager
|
||||
def sudoAs(self, login):
|
||||
def sudo(self, login):
|
||||
old_uid = self.uid
|
||||
try:
|
||||
user = self.env['res.users'].sudo().search([('login', '=', login)])
|
||||
@@ -125,13 +152,11 @@ class BaseFunctionalTest(common.SavepointCase):
|
||||
# switch user
|
||||
self.uid = user.id
|
||||
self.env = self.env(user=self.uid)
|
||||
self.test_record = self.test_record.sudo(self.uid)
|
||||
yield
|
||||
finally:
|
||||
# back
|
||||
self.uid = old_uid
|
||||
self.env = self.env(user=self.uid)
|
||||
self.test_record = self.test_record_old
|
||||
|
||||
|
||||
class TestRecipients(common.SavepointCase):
|
||||
@@ -147,10 +172,16 @@ class TestRecipients(common.SavepointCase):
|
||||
})
|
||||
cls.partner_1 = Partner.create({
|
||||
'name': 'Valid Lelitre',
|
||||
'email': 'valid.lelitre@agrolait.com'})
|
||||
'email': 'valid.lelitre@agrolait.com',
|
||||
'country_id': cls.env.ref('base.be').id,
|
||||
'mobile': '0475001122',
|
||||
})
|
||||
cls.partner_2 = Partner.create({
|
||||
'name': 'Valid Poilvache',
|
||||
'email': 'valid.other@gmail.com'})
|
||||
'email': 'valid.other@gmail.com',
|
||||
'country_id': cls.env.ref('base.be').id,
|
||||
'mobile': '+32 475 22 11 00',
|
||||
})
|
||||
|
||||
|
||||
class MockEmails(common.SingleTransactionCase):
|
||||
@@ -308,6 +339,3 @@ class Moderation(MockEmails, BaseFunctionalTest):
|
||||
'subtype_id': self.env['mail.message.subtype'].search([('name', '=', 'Discussions')]).id
|
||||
})
|
||||
return message
|
||||
|
||||
def _clear_bus(self):
|
||||
self.env['bus.bus'].search([]).unlink()
|
||||
|
||||
@@ -90,7 +90,7 @@ class TestActivityRights(TestActivityCommon):
|
||||
class TestActivityFlow(TestActivityCommon):
|
||||
|
||||
def test_activity_flow_employee(self):
|
||||
with self.sudoAs('ernest'):
|
||||
with self.sudo('ernest'):
|
||||
test_record = self.env['mail.test.activity'].browse(self.test_record.id)
|
||||
self.assertEqual(test_record.activity_ids, self.env['mail.activity'])
|
||||
|
||||
@@ -121,7 +121,7 @@ class TestActivityFlow(TestActivityCommon):
|
||||
def test_activity_flow_portal(self):
|
||||
portal_user = mail_new_test_user(self.env, login='chell', groups='base.group_portal', name='Chell Gladys')
|
||||
|
||||
with self.sudoAs('chell'):
|
||||
with self.sudo('chell'):
|
||||
test_record = self.env['mail.test.activity'].browse(self.test_record.id)
|
||||
with self.assertRaises(exceptions.AccessError):
|
||||
self.env['mail.activity'].create({
|
||||
@@ -164,7 +164,8 @@ class TestActivityMixin(TestActivityCommon):
|
||||
|
||||
def test_activity_mixin(self):
|
||||
self.user_employee.tz = self.user_admin.tz
|
||||
with self.sudoAs('ernest'):
|
||||
with self.sudo('ernest'):
|
||||
self.test_record = self.env['mail.test.activity'].browse(self.test_record.id)
|
||||
self.assertEqual(self.test_record.env.user, self.user_employee)
|
||||
|
||||
now_utc = datetime.now(pytz.UTC)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import tests
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'Mail Tests (Full)',
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'sequence': 9876,
|
||||
'summary': 'Mail Tests: performances and tests specific to mail with all sub-modules',
|
||||
'description': """This module contains tests related to various mail features
|
||||
and mail-related sub modules. Those tests are contained in a separate module as it
|
||||
contains models used only to perform tests independently to functional aspects of
|
||||
real applications. """,
|
||||
'depends': [
|
||||
'test_mail',
|
||||
'mail',
|
||||
'mail_bot',
|
||||
# 'snailmail',
|
||||
'mass_mailing',
|
||||
'phone_validation',
|
||||
'sms',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'demo': [
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_mail_models
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class MailTestSMS(models.Model):
|
||||
""" A model inheriting from mail.thread with some fields used for SMS
|
||||
gateway, like a partner, a specific mobile phone, ... """
|
||||
_description = 'Chatter Model for SMS Gateway'
|
||||
_name = 'mail.test.sms'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char()
|
||||
subject = fields.Char()
|
||||
email_from = fields.Char()
|
||||
phone_nbr = fields.Char()
|
||||
mobile_nbr = fields.Char()
|
||||
customer_id = fields.Many2one('res.partner', 'Customer')
|
||||
|
||||
@api.multi
|
||||
def _get_default_sms_recipients(self):
|
||||
return self.mapped('customer_id')
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_mail_test_sms_all,mail.test.sms.all,model_mail_test_sms,,0,0,0,0
|
||||
access_mail_test_sms_user,mail.test.sms.user,model_mail_test_sms,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import common
|
||||
from . import test_sms_composer
|
||||
from . import test_sms_post
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.addons.test_mail.tests import common as test_mail_common
|
||||
|
||||
|
||||
class BaseFunctionalTest(test_mail_common.BaseFunctionalTest):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(BaseFunctionalTest, cls).setUpClass()
|
||||
cls.user_employee.write({'login': 'employee'})
|
||||
|
||||
# update country to belgium in order to test sanitization of numbers
|
||||
cls.user_employee.company_id.write({'country_id': cls.env.ref('base.be').id})
|
||||
@@ -0,0 +1,74 @@
|
||||
# -*- 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
|
||||
from odoo.addons.phone_validation.tools.phone_validation import phone_format
|
||||
|
||||
|
||||
class TestSMSComposer(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSMSComposer, cls).setUpClass()
|
||||
cls._test_body = 'VOID CONTENT'
|
||||
cls.partner_numbers = [
|
||||
phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164')
|
||||
for partner in (cls.partner_1 | cls.partner_2)
|
||||
]
|
||||
cls.random_numbers_str = '+32475998877, 0475997788'
|
||||
cls.random_numbers = [phone_format(number, 'BE', '32', force_format='E164') for number in ['+32475998877', '0475997788']]
|
||||
|
||||
def test_composer_no_model(self):
|
||||
composer = self.env['sms.composer'].with_context().create({
|
||||
'message': self._test_body,
|
||||
'recipients': '+32475998877, 0475997788'
|
||||
})
|
||||
|
||||
with self.mockSMSGateway():
|
||||
composer.action_send_sms()
|
||||
# self.assertSMSSent(self.random_numbers, self._test_body) # TDE FIXME: actually sanitizer is not called on numbers in current master (saas 12.3)
|
||||
self.assertSMSSent('+32475998877, 0475997788'.split(', '), self._test_body)
|
||||
|
||||
def test_composer_no_mail_thread(self):
|
||||
composer = self.env['sms.composer'].with_context(active_model='test_performance.base').create({
|
||||
'message': self._test_body,
|
||||
'recipients': '+32475998877, 0475997788'
|
||||
})
|
||||
|
||||
with self.mockSMSGateway():
|
||||
composer.action_send_sms()
|
||||
# self.assertSMSSent(self.random_numbers, self._test_body) # TDE FIXME: actually sanitizer is not called on numbers in current master (saas 12.3)
|
||||
self.assertSMSSent('+32475998877, 0475997788'.split(', '), self._test_body)
|
||||
|
||||
def test_composer_partners_active_domain(self):
|
||||
partners = self.partner_1 | self.partner_2
|
||||
composer = self.env['sms.composer'].with_context(
|
||||
active_model='res.partner',
|
||||
active_domain=[('id', 'in', partners.ids)]
|
||||
).create({
|
||||
'message': self._test_body,
|
||||
})
|
||||
|
||||
with self.mockSMSGateway():
|
||||
composer.action_send_sms()
|
||||
self.assertSMSSent(self.partner_numbers, self._test_body)
|
||||
|
||||
def test_composer_partners_sanitize(self):
|
||||
partner_incorrect = self.env['res.partner'].create({
|
||||
'name': 'Jean-Claude Incorrect',
|
||||
'email': 'jean.claude@example.com',
|
||||
'mobile': 'coincoin',
|
||||
})
|
||||
partners = self.partner_1 | self.partner_2 | partner_incorrect
|
||||
composer = self.env['sms.composer'].with_context(
|
||||
active_model='res.partner',
|
||||
active_domain=[('id', 'in', partners.ids)]
|
||||
).create({
|
||||
'message': self._test_body,
|
||||
})
|
||||
|
||||
with self.mockSMSGateway():
|
||||
composer.action_send_sms()
|
||||
# self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body) # TDE FIXME: actually sanitizer does not work in current master (saas 12.23))
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- 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 TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSMSPost, 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)
|
||||
|
||||
def test_message_post_with_sms_on_partners(self):
|
||||
test_body = 'Void body'
|
||||
with self.mockSMSGateway():
|
||||
(self.partner_1 | self.partner_2).message_post_send_sms(test_body)
|
||||
self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body)
|
||||
|
||||
def test_message_post_with_sms_w_default(self):
|
||||
test_body = 'Void body'
|
||||
with self.mockSMSGateway():
|
||||
self.test_record.message_post_send_sms(test_body)
|
||||
self.assertSMSSent(self.partner_1.mapped('mobile'), test_body)
|
||||
self.assertIn(test_body, self.test_record.message_ids.body)
|
||||
|
||||
def test_message_post_with_sms_w_numbers(self):
|
||||
test_body = 'Void body'
|
||||
test_numbers = ['0475114477', '0475225588']
|
||||
with self.mockSMSGateway():
|
||||
self.test_record.message_post_send_sms(test_body, numbers=test_numbers)
|
||||
self.assertSMSSent(test_numbers, test_body)
|
||||
self.assertIn(test_body, self.test_record.message_ids.body)
|
||||
|
||||
def test_message_post_with_sms_w_numbers_duplicate(self):
|
||||
test_body = 'Void body'
|
||||
test_numbers = ['0475114477', '0475225588', '0475114477']
|
||||
with self.mockSMSGateway():
|
||||
self.test_record.message_post_send_sms(test_body, numbers=test_numbers)
|
||||
self.assertSMSSent(test_numbers, test_body)
|
||||
self.assertIn(test_body, self.test_record.message_ids.body)
|
||||
|
||||
def test_message_post_with_sms_w_partners(self):
|
||||
test_body = 'Void body'
|
||||
with self.mockSMSGateway():
|
||||
self.test_record.message_post_send_sms(test_body, partners=self.partner_1 | self.partner_2)
|
||||
self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body)
|
||||
self.assertIn(test_body, self.test_record.message_ids.body)
|
||||
Reference in New Issue
Block a user