Initial squashed commit

This commit is contained in:
hoangvv
2026-05-12 18:58:14 +07:00
commit 0b5c2ce91a
54896 changed files with 28050323 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import tools
from . import wizard
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'SMS gateway',
'version': '3.0',
'category': 'Hidden/Tools',
'summary': 'SMS Text Messaging',
'description': """
This module gives a framework for SMS text messaging
----------------------------------------------------
The service is provided by the In App Purchase Odoo platform.
""",
'depends': [
'base',
'iap_mail',
'mail',
'phone_validation'
],
'data': [
'data/iap_service_data.xml',
'data/ir_cron_data.xml',
'wizard/sms_account_code_views.xml',
'wizard/sms_account_phone_views.xml',
'wizard/sms_account_sender_views.xml',
'wizard/sms_composer_views.xml',
'wizard/sms_template_preview_views.xml',
'wizard/sms_resend_views.xml',
'wizard/sms_template_reset_views.xml',
'views/ir_actions_server_views.xml',
'views/mail_notification_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
'views/iap_account_views.xml',
'views/sms_sms_views.xml',
'views/sms_template_views.xml',
'security/ir.model.access.csv',
'security/sms_security.xml',
],
'demo': [
'data/sms_demo.xml',
'data/mail_demo.xml',
],
'installable': True,
'auto_install': True,
'assets': {
'web.assets_backend': [
'sms/static/src/**/*',
],
'web.assets_unit_tests': [
'sms/static/tests/**/*',
],
},
'license': 'LGPL-3',
}
+3
View File
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+48
View File
@@ -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
request.env['sms.sms'].sudo().search([('uuid', 'in', all_uuids), ('to_delete', '=', False)]).to_delete = True
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")
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="iap_service_sms" model="iap.service">
<field name="name">SMS</field>
<field name="technical_name">sms</field>
<field name="description">Send SMS to your contacts directly from your database.</field>
<field name="unit_name">Credits</field>
<field name="integer_balance">False</field>
</record>
</data>
</odoo>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record forcecreate="True" id="ir_cron_sms_scheduler_action" model="ir.cron">
<field name="name">SMS: SMS Queue Manager</field>
<field name="model_id" ref="model_sms_sms"/>
<field name="state">code</field>
<field name="code">model._process_queue()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
</record>
</data></odoo>
+90
View File
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="message_demo_partner_1_0" model="mail.message">
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_28"/>
<field name="body" type="html"><p>Hello! This is an example of incoming email.</p></field>
<field name="message_type">email</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(days=5)).strftime('%Y-%m-%d %H:%M:00')"/>
</record>
<record id="message_demo_partner_1_1" model="mail.message">
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_28"/>
<field name="body" type="html"><p>Hello! This is an example of user comment.</p></field>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_admin"/>
<field name="date" eval="(DateTime.today() - timedelta(days=4)).strftime('%Y-%m-%d %H:%M:00')"/>
</record>
<record id="message_demo_partner_1_2_notif_0" model="mail.notification">
<field name="author_id" ref="base.partner_admin"/>
<field name="mail_message_id" ref="message_demo_partner_1_1"/>
<field name="res_partner_id" ref="base.res_partner_address_28"/>
<field name="notification_type">email</field>
<field name="notification_status">exception</field>
<field name="failure_type">mail_smtp</field>
</record>
<record id="message_demo_partner_1_2" model="mail.message">
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_28"/>
<field name="body" type="html"><p>Hello! This is an example of SMS.</p></field>
<field name="message_type">sms</field>
<field name="subtype_id" ref="mail.mt_note"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(days=3)).strftime('%Y-%m-%d %H:%M:00')"/>
</record>
<record id="message_demo_partner_1_3" model="mail.message">
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_28"/>
<field name="body" type="html"><p>Hello! This is an example of another SMS with notifications and an unregistered account.</p></field>
<field name="message_type">sms</field>
<field name="subtype_id" ref="mail.mt_note"/>
<field name="author_id" ref="base.partner_admin"/>
<field name="date" eval="(DateTime.today() - timedelta(days=2)).strftime('%Y-%m-%d %H:%M:00')"/>
</record>
<record id="message_demo_partner_1_3_notif_0" model="mail.notification">
<field name="author_id" ref="base.partner_admin"/>
<field name="mail_message_id" ref="message_demo_partner_1_3"/>
<field name="res_partner_id" ref="base.res_partner_address_28"/>
<field name="notification_type">sms</field>
<field name="notification_status">exception</field>
<field name="failure_type">sms_acc</field>
</record>
<record id="message_demo_partner_1_4" model="mail.message">
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_28"/>
<field name="body" type="html"><p>Hello! This is an example of a sent SMS with notifications.</p></field>
<field name="message_type">sms</field>
<field name="subtype_id" ref="mail.mt_note"/>
<field name="author_id" ref="base.partner_admin"/>
<field name="date" eval="(DateTime.today() - timedelta(days=1,hours=22)).strftime('%Y-%m-%d %H:%M:00')"/>
</record>
<record id="message_demo_partner_1_4_notif_0" model="mail.notification">
<field name="author_id" ref="base.partner_admin"/>
<field name="mail_message_id" ref="message_demo_partner_1_4"/>
<field name="res_partner_id" ref="base.res_partner_address_28"/>
<field name="notification_type">sms</field>
<field name="notification_status">sent</field>
</record>
<record id="message_demo_partner_1_5" model="mail.message">
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_16"/>
<field name="body" type="html"><p>Hello! This is an example of another SMS with notifications without credits.</p></field>
<field name="message_type">sms</field>
<field name="subtype_id" ref="mail.mt_note"/>
<field name="author_id" ref="base.partner_admin"/>
<field name="date" eval="(DateTime.today() - timedelta(days=1)).strftime('%Y-%m-%d %H:%M:00')"/>
</record>
<record id="message_demo_partner_1_5_notif_0" model="mail.notification">
<field name="author_id" ref="base.partner_admin"/>
<field name="mail_message_id" ref="message_demo_partner_1_5"/>
<field name="res_partner_id" ref="base.res_partner_address_16"/>
<field name="notification_type">sms</field>
<field name="notification_status">exception</field>
<field name="failure_type">sms_credit</field>
</record>
</data></odoo>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="sms_template_demo_0" model="sms.template">
<field name="name">Customer: automated SMS</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="body">Dear {{ object.display_name }} this is an automated SMS.</field>
</record>
</data></odoo>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import iap_account
from . import ir_actions_server
from . import ir_model
from . import mail_followers
from . import mail_message
from . import mail_notification
from . import mail_thread
from . import models
from . import res_company
from . import res_partner
from . import sms_sms
from . import sms_template
from . import sms_tracker
+35
View File
@@ -0,0 +1,35 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
class IapAccount(models.Model):
_inherit = 'iap.account'
sender_name = fields.Char(help="This is the name that will be displayed as the sender of the SMS.", readonly=True)
def action_open_registration_wizard(self):
return {
'type': 'ir.actions.act_window',
'target': 'new',
'name': _('Register Account'),
'view_mode': 'form',
'res_model': 'sms.account.phone',
'context': {'default_account_id': self.id},
}
def action_open_sender_name_wizard(self):
return {
'type': 'ir.actions.act_window',
'target': 'new',
'name': _('Choose your sender name'),
'view_mode': 'form',
'res_model': 'sms.account.sender',
'context': {'default_account_id': self.id},
}
def _get_account_info(self, account_id, balance, information):
res = super()._get_account_info(account_id, balance, information)
if account_id.service_name == 'sms':
res['sender_name'] = information.get('sender_name')
return res
+89
View File
@@ -0,0 +1,89 @@
# -*- 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 ValidationError
class ServerActions(models.Model):
""" Add SMS option in server actions. """
_name = 'ir.actions.server'
_inherit = ['ir.actions.server']
state = fields.Selection(selection_add=[
('sms', 'Send SMS'), ('followers',),
], ondelete={'sms': 'cascade'})
# SMS
sms_template_id = fields.Many2one(
'sms.template', 'SMS Template',
compute='_compute_sms_template_id',
ondelete='set null', readonly=False, store=True,
domain="[('model_id', '=', model_id)]",
)
sms_method = fields.Selection(
selection=[('sms', 'SMS (without note)'), ('comment', 'SMS (with note)'), ('note', 'Note only')],
string='Send SMS As',
compute='_compute_sms_method',
readonly=False, store=True)
@api.depends('state')
def _compute_available_model_ids(self):
mail_thread_based = self.filtered(lambda action: action.state == 'sms')
if mail_thread_based:
mail_models = self.env['ir.model'].search([('is_mail_thread', '=', True), ('transient', '=', False)])
for action in mail_thread_based:
action.available_model_ids = mail_models.ids
super(ServerActions, self - mail_thread_based)._compute_available_model_ids()
@api.depends('model_id', 'state')
def _compute_sms_template_id(self):
to_reset = self.filtered(
lambda act: act.state != 'sms' or \
(act.model_id != act.sms_template_id.model_id)
)
if to_reset:
to_reset.sms_template_id = False
@api.depends('state')
def _compute_sms_method(self):
to_reset = self.filtered(lambda act: act.state != 'sms')
if to_reset:
to_reset.sms_method = False
other = self - to_reset
if other:
other.sms_method = 'sms'
@api.constrains('state', 'model_id')
def _check_sms_model_coherency(self):
for action in self:
if action.state == 'sms' and (action.model_id.transient or not action.model_id.is_mail_thread):
raise ValidationError(_("Sending SMS can only be done on a not transient mail.thread model"))
@api.constrains('model_id', 'template_id')
def _check_sms_template_model(self):
for action in self.filtered(lambda action: action.state == 'sms'):
if action.sms_template_id and action.sms_template_id.model_id != action.model_id:
raise ValidationError(
_('SMS template model of %(action_name)s does not match action model.',
action_name=action.name
)
)
def _run_action_sms_multi(self, eval_context=None):
# TDE CLEANME: when going to new api with server action, remove action
if not self.sms_template_id or self._is_recompute():
return False
records = eval_context.get('records') or eval_context.get('record')
if not records:
return False
composer = self.env['sms.composer'].with_context(
default_res_model=records._name,
default_res_ids=records.ids,
default_composition_mode='comment' if self.sms_method == 'comment' else 'mass',
default_template_id=self.sms_template_id.id,
default_mass_keep_log=self.sms_method == 'note',
).create({})
composer.action_send_sms()
return False
+41
View File
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class IrModel(models.Model):
_inherit = 'ir.model'
is_mail_thread_sms = fields.Boolean(
string="Mail Thread SMS", default=False,
store=False, compute='_compute_is_mail_thread_sms', search='_search_is_mail_thread_sms',
help="Whether this model supports messages and notifications through SMS",
)
@api.depends('is_mail_thread')
def _compute_is_mail_thread_sms(self):
for model in self:
if model.is_mail_thread:
ModelObject = self.env[model.model]
potential_fields = ModelObject._phone_get_number_fields() + ModelObject._mail_get_partner_fields()
if any(fname in ModelObject._fields for fname in potential_fields):
model.is_mail_thread_sms = True
continue
model.is_mail_thread_sms = False
def _search_is_mail_thread_sms(self, operator, value):
thread_models = self.search([('is_mail_thread', '=', True)])
valid_models = self.env['ir.model']
for model in thread_models:
if model.model not in self.env:
continue
ModelObject = self.env[model.model]
potential_fields = ModelObject._phone_get_number_fields() + ModelObject._mail_get_partner_fields()
if any(fname in ModelObject._fields for fname in potential_fields):
valid_models |= model
search_sms = (operator == '=' and value) or (operator == '!=' and not value)
if search_sms:
return [('id', 'in', valid_models.ids)]
return [('id', 'not in', valid_models.ids)]
+29
View File
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Followers(models.Model):
_inherit = ['mail.followers']
def _get_recipient_data(self, records, message_type, subtype_id, pids=None):
recipients_data = super()._get_recipient_data(records, message_type, subtype_id, pids=pids)
if message_type != 'sms' or not (pids or records):
return recipients_data
if pids is None and records:
records_pids = dict(
(rec_id, partners.ids)
for rec_id, partners in records._mail_get_partners().items()
)
elif pids and records:
records_pids = dict((record.id, pids) for record in records)
else:
records_pids = {0: pids if pids else []}
for rid, rdata in recipients_data.items():
sms_pids = records_pids.get(rid) or []
for pid, pdata in rdata.items():
if pid in sms_pids:
pdata['notif'] = 'sms'
return recipients_data
+29
View File
@@ -0,0 +1,29 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailMessage(models.Model):
""" Override MailMessage class in order to add a new type: SMS messages.
Those messages comes with their own notification method, using SMS
gateway. """
_inherit = 'mail.message'
message_type = fields.Selection(
selection_add=[('sms', 'SMS')],
ondelete={'sms': lambda recs: recs.write({'message_type': 'comment'})})
has_sms_error = fields.Boolean(
'Has SMS error', compute='_compute_has_sms_error', search='_search_has_sms_error')
def _compute_has_sms_error(self):
sms_error_from_notification = self.env['mail.notification'].sudo().search([
('notification_type', '=', 'sms'),
('mail_message_id', 'in', self.ids),
('notification_status', '=', 'exception')]).mapped('mail_message_id')
for message in self:
message.has_sms_error = message in sms_error_from_notification
def _search_has_sms_error(self, operator, operand):
if operator == '=' and operand:
return ['&', ('notification_ids.notification_status', '=', 'exception'), ('notification_ids.notification_type', '=', 'sms')]
raise NotImplementedError()
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class MailNotification(models.Model):
_inherit = 'mail.notification'
notification_type = fields.Selection(selection_add=[
('sms', 'SMS')
], ondelete={'sms': 'cascade'})
sms_id_int = fields.Integer('SMS ID', index='btree_not_null')
# Used to give links on form view without foreign key. In most cases, you'd want to use sms_id_int or sms_tracker_ids.sms_uuid.
sms_id = fields.Many2one('sms.sms', string='SMS', store=False, compute='_compute_sms_id')
sms_tracker_ids = fields.One2many('sms.tracker', 'mail_notification_id', string="SMS Trackers")
sms_number = fields.Char('SMS Number', groups='base.group_user')
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'),
# 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.depends('sms_id_int', 'notification_type')
def _compute_sms_id(self):
self.sms_id = False
sms_notifications = self.filtered(lambda n: n.notification_type == 'sms' and bool(n.sms_id_int))
if not sms_notifications:
return
existing_sms_ids = self.env['sms.sms'].sudo().search([
('id', 'in', sms_notifications.mapped('sms_id_int')), ('to_delete', '!=', True)
]).ids
for sms_notification in sms_notifications.filtered(lambda n: n.sms_id_int in set(existing_sms_ids)):
sms_notification.sms_id = sms_notification.sms_id_int
+281
View File
@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, Command, models, fields
from odoo.addons.sms.tools.sms_tools import sms_content_to_rendered_html
from odoo.tools import html2plaintext
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
message_has_sms_error = fields.Boolean(
'SMS Delivery error', compute='_compute_message_has_sms_error', search='_search_message_has_sms_error',
help="If checked, some messages have a delivery error.")
def _compute_message_has_sms_error(self):
res = {}
if self.ids:
self.env.cr.execute("""
SELECT msg.res_id, COUNT(msg.res_id)
FROM mail_message msg
INNER JOIN mail_notification notif
ON notif.mail_message_id = msg.id
WHERE notif.notification_type = 'sms'
AND notif.notification_status = 'exception'
AND notif.author_id = %(author_id)s
AND msg.model = %(model_name)s
AND msg.res_id in %(res_ids)s
AND msg.message_type != 'user_notification'
GROUP BY msg.res_id
""", {'author_id': self.env.user.partner_id.id, 'model_name': self._name, 'res_ids': tuple(self.ids)})
res.update(self._cr.fetchall())
for record in self:
record.message_has_sms_error = bool(res.get(record._origin.id, 0))
@api.model
def _search_message_has_sms_error(self, operator, operand):
return ['&', ('message_ids.has_sms_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)]
@api.returns('mail.message', lambda value: value.id)
def message_post(self, *args, body='', message_type='notification', **kwargs):
# When posting an 'SMS' `message_type`, make sure that the body is used as-is in the sms,
# and reformat the message body for the notification (mainly making URLs clickable).
if message_type == 'sms':
kwargs['sms_content'] = body
body = sms_content_to_rendered_html(body)
return super().message_post(*args, body=body, message_type=message_type, **kwargs)
def _message_sms_schedule_mass(self, body='', template=False, **composer_values):
""" Shortcut method to schedule a mass sms sending on a recordset.
:param template: an optional sms.template record;
"""
composer_context = {
'default_res_model': self._name,
'default_composition_mode': 'mass',
'default_template_id': template.id if template else False,
'default_res_ids': self.ids,
}
if body and not template:
composer_context['default_body'] = body
create_vals = {
'mass_force_send': False,
'mass_keep_log': True,
}
if composer_values:
create_vals.update(composer_values)
composer = self.env['sms.composer'].with_context(**composer_context).create(create_vals)
return composer._action_send_sms()
def _message_sms_with_template(self, template=False, template_xmlid=False, template_fallback='', partner_ids=False, **kwargs):
""" Shortcut method to perform a _message_sms with an sms.template.
:param template: a valid sms.template record;
:param template_xmlid: XML ID of an sms.template (if no template given);
:param template_fallback: plaintext (inline_template-enabled) in case template
and template xml id are falsy (for example due to deleted data);
"""
self.ensure_one()
if not template and template_xmlid:
template = self.env.ref(template_xmlid, raise_if_not_found=False)
if template:
body = template._render_field('body', self.ids, compute_lang=True)[self.id]
else:
body = self.env['sms.template']._render_template(template_fallback, self._name, self.ids)[self.id]
return self._message_sms(body, partner_ids=partner_ids, **kwargs)
def _message_sms(self, body, subtype_id=False, partner_ids=False, number_field=False,
sms_numbers=None, sms_pid_to_number=None, **kwargs):
""" Main method to post a message on a record using SMS-based notification
method.
:param body: content of SMS;
:param subtype_id: mail.message.subtype used in mail.message associated
to the sms notification process;
:param partner_ids: if set is a record set of partners to notify;
:param number_field: if set is a name of field to use on current record
to compute a number to notify;
:param sms_numbers: see ``_notify_thread_by_sms``;
:param sms_pid_to_number: see ``_notify_thread_by_sms``;
"""
self.ensure_one()
sms_pid_to_number = sms_pid_to_number if sms_pid_to_number is not None else {}
if number_field or (partner_ids is False and sms_numbers is None):
info = self._sms_get_recipients_info(force_field=number_field)[self.id]
info_partner_ids = info['partner'].ids if info['partner'] else False
info_number = info['sanitized'] if info['sanitized'] else info['number']
if info_partner_ids and info_number:
sms_pid_to_number[info_partner_ids[0]] = info_number
if info_partner_ids:
partner_ids = info_partner_ids + (partner_ids or [])
if not info_partner_ids:
if info_number:
sms_numbers = [info_number] + (sms_numbers or [])
# will send a falsy notification allowing to fix it through SMS wizards
elif not sms_numbers:
sms_numbers = [False]
if subtype_id is False:
subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')
return self.message_post(
body=body, partner_ids=partner_ids or [], # TDE FIXME: temp fix otherwise crash mail_thread.py
message_type='sms', subtype_id=subtype_id,
sms_numbers=sms_numbers, sms_pid_to_number=sms_pid_to_number,
**kwargs
)
def _notify_thread(self, message, msg_vals=False, **kwargs):
scheduled_date = self._is_notification_scheduled(kwargs.get('scheduled_date'))
recipients_data = super(MailThread, self)._notify_thread(message, msg_vals=msg_vals, **kwargs)
if not scheduled_date:
self._notify_thread_by_sms(message, recipients_data, msg_vals=msg_vals, **kwargs)
return recipients_data
def _notify_thread_by_sms(self, message, recipients_data, msg_vals=False,
sms_content=None, sms_numbers=None, sms_pid_to_number=None,
resend_existing=False, put_in_queue=False, **kwargs):
""" Notification method: by SMS.
:param message: ``mail.message`` record to notify;
:param recipients_data: list of recipients information (based on res.partner
records), formatted like
[{'active': partner.active;
'id': id of the res.partner being recipient to notify;
'groups': res.group IDs if linked to a user;
'notif': 'inbox', 'email', 'sms' (SMS App);
'share': partner.partner_share;
'type': 'customer', 'portal', 'user;'
}, {...}].
See ``MailThread._notify_get_recipients``;
:param msg_vals: dictionary of values used to create the message. If given it
may be used to access values related to ``message`` without accessing it
directly. It lessens query count in some optimized use cases by avoiding
access message content in db;
:param sms_content: plaintext version of body, mainly to avoid
conversion glitches by splitting html and plain text content formatting
(e.g.: links, styling.).
If not given, `msg_vals`'s `body` is used and converted from html to plaintext;
:param sms_numbers: additional numbers to notify in addition to partners
and classic recipients;
:param pid_to_number: force a number to notify for a given partner ID
instead of taking its mobile / phone number;
:param resend_existing: check for existing notifications to update based on
mailed recipient, otherwise create new notifications;
:param put_in_queue: use cron to send queued SMS instead of sending them
directly;
"""
sms_pid_to_number = sms_pid_to_number if sms_pid_to_number is not None else {}
sms_numbers = sms_numbers if sms_numbers is not None else []
sms_create_vals = []
sms_all = self.env['sms.sms'].sudo()
# pre-compute SMS data
body = sms_content or html2plaintext(msg_vals['body'] if msg_vals and 'body' in msg_vals else message.body)
sms_base_vals = {
'body': body,
'mail_message_id': message.id,
'state': 'outgoing',
}
# notify from computed recipients_data (followers, specific recipients)
partners_data = [r for r in recipients_data if r['notif'] == 'sms']
partner_ids = [r['id'] for r in partners_data]
if partner_ids:
for partner in self.env['res.partner'].sudo().browse(partner_ids):
number = sms_pid_to_number.get(partner.id) or partner.mobile or partner.phone
sms_create_vals.append(dict(
sms_base_vals,
partner_id=partner.id,
number=partner._phone_format(number=number) or number,
))
# notify from additional numbers
if sms_numbers:
tocreate_numbers = [
self._phone_format(number=sms_number) or sms_number
for sms_number in sms_numbers
]
existing_partners_numbers = {vals_dict['number'] for vals_dict in sms_create_vals}
sms_create_vals += [dict(
sms_base_vals,
partner_id=False,
number=n,
state='outgoing' if n else 'error',
failure_type='' if n else 'sms_number_missing',
) for n in tocreate_numbers if n not in existing_partners_numbers]
# create sms and notification
existing_pids, existing_numbers = [], []
if sms_create_vals:
sms_all |= self.env['sms.sms'].sudo().create(sms_create_vals)
if resend_existing:
existing = self.env['mail.notification'].sudo().search([
'|', ('res_partner_id', 'in', partner_ids),
'&', ('res_partner_id', '=', False), ('sms_number', 'in', sms_numbers),
('notification_type', '=', 'sms'),
('mail_message_id', 'in', message.ids),
])
for n in existing:
if n.res_partner_id.id in partner_ids and n.mail_message_id == message:
existing_pids.append(n.res_partner_id.id)
if not n.res_partner_id and n.sms_number in sms_numbers and n.mail_message_id == message:
existing_numbers.append(n.sms_number)
notif_create_values = [{
'author_id': message.author_id.id,
'mail_message_id': message.id,
'res_partner_id': sms.partner_id.id,
'sms_number': sms.number,
'notification_type': 'sms',
'sms_id_int': 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,
} for sms in sms_all if (sms.partner_id and sms.partner_id.id not in existing_pids) or (not sms.partner_id and sms.number not in existing_numbers)]
if notif_create_values:
self.env['mail.notification'].sudo().create(notif_create_values)
if existing_pids or existing_numbers:
for sms in sms_all:
notif = next((n for n in existing if
(n.res_partner_id.id in existing_pids and n.res_partner_id.id == sms.partner_id.id) or
(not n.res_partner_id and n.sms_number in existing_numbers and n.sms_number == sms.number)), False)
if notif:
notif.write({
'notification_type': 'sms',
'notification_status': 'ready',
'sms_id_int': sms.id,
'sms_tracker_ids': [Command.create({'sms_uuid': sms.uuid})],
'sms_number': sms.number,
})
if sms_all and not put_in_queue:
sms_all.filtered(lambda sms: sms.state == 'outgoing').send(auto_commit=False, raise_exception=False)
return True
def _get_notify_valid_parameters(self):
return super()._get_notify_valid_parameters() | {
'put_in_queue', 'sms_numbers', 'sms_pid_to_number', 'sms_content',
}
@api.model
def notify_cancel_by_type(self, notification_type):
super().notify_cancel_by_type(notification_type)
if notification_type == 'sms':
# TDE CHECK: delete pending SMS
self._notify_cancel_by_type_generic('sms')
return True
+89
View File
@@ -0,0 +1,89 @@
from odoo import models
from odoo.addons.phone_validation.tools import phone_validation
class BaseModel(models.AbstractModel):
_inherit = 'base'
def _sms_get_recipients_info(self, force_field=False, partner_fallback=True):
"""" Get SMS recipient information on current record set. This method
checks for numbers and sanitation in order to centralize computation.
Example of use cases
* click on a field -> number is actually forced from field, find customer
linked to record, force its number to field or fallback on customer fields;
* contact -> find numbers from all possible phone fields on record, find
customer, force its number to found field number or fallback on customer fields;
:param force_field: either give a specific field to find phone number, either
generic heuristic is used to find one based on ``_phone_get_number_fields``;
:param partner_fallback: if no value found in the record, check its customer
values based on ``_mail_get_partners``;
:return dict: record.id: {
'partner': a res.partner recordset that is the customer (void or singleton)
linked to the recipient. See ``_mail_get_partners``;
'sanitized': sanitized number to use (coming from record's field or partner's
phone fields). Set to False is number impossible to parse and format;
'number': original number before sanitation;
'partner_store': whether the number comes from the customer phone fields. If
False it means number comes from the record itself, even if linked to a
customer;
'field_store': field in which the number has been found (generally mobile or
phone, see ``_phone_get_number_fields``);
} for each record in self
"""
result = dict.fromkeys(self.ids, False)
tocheck_fields = [force_field] if force_field else self._phone_get_number_fields()
for record in self:
all_numbers = [record[fname] for fname in tocheck_fields if fname in record]
all_partners = record._mail_get_partners()[record.id]
valid_number, fname = False, False
for fname in [f for f in tocheck_fields if f in record]:
valid_number = record._phone_format(fname=fname)
if valid_number:
break
if valid_number:
result[record.id] = {
'partner': all_partners[0] if all_partners else self.env['res.partner'],
'sanitized': valid_number,
'number': record[fname],
'partner_store': False,
'field_store': fname,
}
elif all_partners and partner_fallback:
partner = self.env['res.partner']
for partner in all_partners:
for fname in self.env['res.partner']._phone_get_number_fields():
valid_number = partner._phone_format(fname=fname)
if valid_number:
break
if not valid_number:
fname = 'mobile' if partner.mobile else ('phone' if partner.phone else 'mobile')
result[record.id] = {
'partner': partner,
'sanitized': valid_number if valid_number else False,
'number': partner[fname],
'partner_store': True,
'field_store': fname,
}
else:
# did not find any sanitized number -> take first set value as fallback;
# if none, just assign False to the first available number field
value, fname = next(
((value, fname) for value, fname in zip(all_numbers, tocheck_fields) if value),
(False, tocheck_fields[0] if tocheck_fields else False)
)
result[record.id] = {
'partner': self.env['res.partner'],
'sanitized': False,
'number': value,
'partner_store': False,
'field_store': fname
}
return result
+11
View File
@@ -0,0 +1,11 @@
from odoo import models
from odoo.addons.sms.tools.sms_api import SmsApi
class ResCompany(models.Model):
_inherit = 'res.company'
def _get_sms_api_class(self):
self.ensure_one()
return SmsApi
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResPartner(models.Model):
_name = 'res.partner'
_inherit = ['mail.thread.phone', 'res.partner']
+250
View File
@@ -0,0 +1,250 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import threading
from uuid import uuid4
from werkzeug.urls import url_join
from odoo import api, fields, models, tools, _
from odoo.addons.sms.tools.sms_api import SmsApi
_logger = logging.getLogger(__name__)
class SmsSms(models.Model):
_name = 'sms.sms'
_description = 'Outgoing SMS'
_rec_name = 'number'
_order = 'id DESC'
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 = { # TODO RIGR remove me in master
'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'),
('process', 'Processing'),
('pending', 'Sent'),
('sent', 'Delivered'), # As for notifications and traces
('error', 'Error'),
('canceled', 'Cancelled')
], '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, 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')
to_delete = fields.Boolean(
'Marked for deletion', default=False,
help='Will automatically be deleted, while notifications will not be deleted in any case.'
)
_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._update_sms_state_and_trackers('canceled')
def action_set_error(self, failure_type):
self._update_sms_state_and_trackers('error', failure_type=failure_type)
def action_set_outgoing(self):
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.
:param unlink_failed: unlink failed SMS after IAP feedback;
:param unlink_sent: unlink sent SMS after IAP feedback;
:param auto_commit: commit after each batch of SMS;
:param raise_exception: raise if there is an issue contacting IAP;
"""
to_send = self.filtered(lambda sms: sms.state == 'outgoing' and not sms.to_delete)
for sms_api, sms in to_send._split_by_api():
for batch_ids in sms._split_batch():
self.env['sms.sms'].browse(batch_ids).with_context(sms_api=sms_api)._send(
unlink_failed=unlink_failed,
unlink_sent=unlink_sent,
raise_exception=raise_exception,
)
# auto-commit if asked except in testing mode
if auto_commit is True and not getattr(threading.current_thread(), 'testing', False):
self._cr.commit()
def _split_by_api(self):
yield SmsApi(self.env), self
def resend_failed(self):
sms_to_send = self.filtered(lambda sms: sms.state == 'error' and not sms.to_delete)
sms_to_send.state = 'outgoing'
notification_title = _('Warning')
notification_type = 'danger'
if sms_to_send:
sms_to_send.send()
success_sms = len(sms_to_send) - len(sms_to_send.exists())
if success_sms > 0:
notification_title = _('Success')
notification_type = 'success'
notification_message = _('%(count)s out of the %(total)s selected SMS Text Messages have successfully been resent.', count=success_sms, total=len(self))
else:
notification_message = _('The SMS Text Messages could not be resent.')
else:
notification_message = _('There are no SMS Text Messages to resend.')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': notification_title,
'message': notification_message,
'type': notification_type,
}
}
@api.model
def _process_queue(self, ids=None):
""" Send immediately queued messages, committing after each message is sent.
This is not transactional and should not be called during another transaction!
:param list ids: optional list of emails ids to send. If passed no search
is performed, and these ids are used instead.
"""
domain = [('state', '=', 'outgoing'), ('to_delete', '!=', True)]
filtered_ids = self.search(domain, limit=10000).ids # TDE note: arbitrary limit we might have to update
if ids:
ids = list(set(filtered_ids) & set(ids))
else:
ids = filtered_ids
ids.sort()
res = None
try:
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
res = self.browse(ids).send(unlink_failed=False, unlink_sent=True, auto_commit=auto_commit, raise_exception=False)
except Exception:
_logger.exception("Failed processing SMS queue")
return res
def _get_sms_company(self):
return self.mail_message_id.record_company_id or self.env.company
def _get_batch_size(self):
return int(self.env['ir.config_parameter'].sudo().get_param('sms.session.batch.size', 500))
def _split_batch(self):
batch_size = self._get_batch_size()
for sms_batch in tools.split_every(batch_size, self.ids):
yield sms_batch
def _send(self, unlink_failed=False, unlink_sent=True, raise_exception=False):
"""Send SMS after checking the number (presence and formatting)."""
sms_api = self.env.context.get('sms_api')
if not sms_api:
company = self._get_sms_company()
company.ensure_one() # This should always be the case since the grouping is done in `send`
sms_api = company._get_sms_api_class()(self.env)
return self._send_with_api(
sms_api,
unlink_failed=unlink_failed,
unlink_sent=unlink_sent,
raise_exception=raise_exception,
)
def _send_with_api(self, sms_api, unlink_failed=False, unlink_sent=True, raise_exception=False):
"""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()]
delivery_reports_url = url_join(self[0].get_base_url(), '/sms/status')
try:
results = sms_api._send_sms_batch(messages, delivery_reports_url=delivery_reports_url)
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
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, results)
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)
for (iap_state, failure_reason), results_group in tools.groupby(results, key=lambda result: (result['state'], result.get('failure_reason'))):
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)
to_delete = {'to_delete': True} if unlink_sent else {}
sms_sudo.write({'state': success_state, 'failure_type': False, **to_delete})
else:
failure_type = sms_api.PROVIDER_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, failure_reason=failure_reason)
else:
sms_sudo.sms_tracker_id.with_context(sms_known_failure_reason=failure_reason)._action_update_from_provider_error(iap_state)
to_delete = {'to_delete': True} if unlink_failed else {}
sms_sudo.write({'state': 'error', 'failure_type': failure_type, **to_delete})
all_sms_sudo._handle_call_result_hook(results)
all_sms_sudo.mail_message_id._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})
# Use sudo on mail.notification to allow writing other users' notifications; rights are already checked by sms write
self.sms_tracker_id.sudo()._action_update_from_sms_state(new_state, failure_type=failure_type)
def _handle_call_result_hook(self, results):
"""Further process SMS sending API results."""
pass
@api.autovacuum
def _gc_device(self):
self._cr.execute("DELETE FROM sms_sms WHERE to_delete = TRUE")
_logger.info("GC'd %d sms marked for deletion", self._cr.rowcount)
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
class SMSTemplate(models.Model):
"Templates for sending SMS"
_name = "sms.template"
_inherit = ['mail.render.mixin', 'template.reset.mixin']
_description = 'SMS Templates'
_unrestricted_rendering = True
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if 'model_id' in fields and not res.get('model_id') and res.get('model'):
res['model_id'] = self.env['ir.model']._get(res['model']).id
return res
name = fields.Char('Name', translate=True)
model_id = fields.Many2one(
'ir.model', string='Applies to', required=True,
domain=['&', ('is_mail_thread_sms', '=', True), ('transient', '=', False)],
help="The type of document this template can be used with", ondelete='cascade')
model = fields.Char('Related Document Model', related='model_id.model', index=True, store=True, readonly=True)
body = fields.Char('Body', translate=True, required=True)
# Use to create contextual action (same as for email template)
sidebar_action_id = fields.Many2one('ir.actions.act_window', 'Sidebar action', readonly=True, copy=False,
help="Sidebar action to make this template available on records "
"of the related document model")
# Overrides of mail.render.mixin
@api.depends('model')
def _compute_render_model(self):
for template in self:
template.render_model = template.model
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", template.name)) for template, vals in zip(self, vals_list)]
def unlink(self):
self.sudo().mapped('sidebar_action_id').unlink()
return super(SMSTemplate, self).unlink()
def action_create_sidebar_action(self):
ActWindow = self.env['ir.actions.act_window']
view = self.env.ref('sms.sms_composer_view_form')
for template in self:
button_name = _('Send SMS (%s)', template.name)
action = ActWindow.create({
'name': button_name,
'type': 'ir.actions.act_window',
'res_model': 'sms.composer',
# Add default_composition_mode to guess to determine if need to use mass or comment composer
'context': "{'default_template_id' : %d, 'sms_composition_mode': 'guess', 'default_res_ids': active_ids, 'default_res_id': active_id}" % (template.id),
'view_mode': 'form',
'view_id': view.id,
'target': 'new',
'binding_model_id': template.model_id.id,
})
template.write({'sidebar_action_id': action.id})
return True
def action_unlink_sidebar_action(self):
for template in self:
if template.sidebar_action_id:
template.sidebar_action_id.unlink()
return True
+82
View File
@@ -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 = self.env.context.get("sms_known_failure_reason") # TODO RIGR in master: pass as param instead of context
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 = failure_reason or 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()
+16
View File
@@ -0,0 +1,16 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sms_sms_all,access.sms.sms.all,model_sms_sms,,0,0,0,0
access_sms_sms_system,access.sms.sms.system,model_sms_sms,base.group_system,1,1,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
access_sms_template_preview,access.sms.template.preview,model_sms_template_preview,base.group_user,1,1,1,0
access_sms_template_reset,access.sms.template.reset,model_sms_template_reset,mail.group_mail_template_editor,1,1,1,1
access_sms_account_registration_phone_number_wizard_system,access.sms.account.phone.system,model_sms_account_phone,base.group_system,1,1,1,1
access_sms_account_verification_code_wizard_system,access.sms.account.code.system,model_sms_account_code,base.group_system,1,1,1,1
access_sms_account_sender_name_wizard_system,access.sms.account.sender.system,model_sms_account_sender,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_sms_sms_all access.sms.sms.all model_sms_sms 0 0 0 0
3 access_sms_sms_system access.sms.sms.system model_sms_sms base.group_system 1 1 1 1
4 access_sms_template_all access.sms.template.all model_sms_template 0 0 0 0
5 access_sms_template_user access.sms.template.user model_sms_template base.group_user 1 0 0 0
6 access_sms_template_system access.sms.template.system model_sms_template base.group_system 1 1 1 1
7 access_sms_tracker_all access.sms.tracker.all model_sms_tracker 0 0 0 0
8 access_sms_tracker_system access.sms.tracker.system model_sms_tracker base.group_system 1 1 1 1
9 access_sms_composer access.sms.composer model_sms_composer base.group_user 1 1 1 0
10 access_sms_resend_recipient access.sms.resend.recipient model_sms_resend_recipient base.group_user 1 1 1 0
11 access_sms_resend access.sms.resend model_sms_resend base.group_user 1 1 1 0
12 access_sms_template_preview access.sms.template.preview model_sms_template_preview base.group_user 1 1 1 0
13 access_sms_template_reset access.sms.template.reset model_sms_template_reset mail.group_mail_template_editor 1 1 1 1
14 access_sms_account_registration_phone_number_wizard_system access.sms.account.phone.system model_sms_account_phone base.group_system 1 1 1 1
15 access_sms_account_verification_code_wizard_system access.sms.account.code.system model_sms_account_code base.group_system 1 1 1 1
16 access_sms_account_sender_name_wizard_system access.sms.account.sender.system model_sms_account_sender base.group_system 1 1 1 1
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_rule_sms_template_system" model="ir.rule">
<field name="name">SMS Template: system group granted all</field>
<field name="model_id" ref="sms.model_sms_template"/>
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
</odoo>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M13 8a4 4 0 0 1 4-4h16a4 4 0 0 1 4 4v34a4 4 0 0 1-4 4H17a4 4 0 0 1-4-4V8Z" fill="#1AD3BB"/><path d="M37 19v18a9 9 0 1 1 0-18Z" fill="#1A6F66"/><path d="M13 31V13a9 9 0 1 1 0 18Z" fill="#005E7A"/><path d="M13 13H4v9a9 9 0 0 0 9 9V13Z" fill="#985184"/><path d="M37 37h9v-9a9 9 0 0 0-9-9v18Z" fill="#FC868B"/></svg>

After

Width:  |  Height:  |  Size: 405 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><defs><path id="a" d="M91.974 123.535v-17.07c0-.839-.283-1.542-.851-2.111-.568-.57-1.24-.854-2.017-.854H71.894c-.777 0-1.449.285-2.017.854-.568.569-.851 1.272-.851 2.11v17.071c0 .839.283 1.542.851 2.111.568.57 1.24.854 2.017.854h17.212c.777 0 1.449-.285 2.017-.854.568-.569.851-1.272.851-2.11zm-.179-33.601l1.614-41.239c0-.718-.3-1.287-.897-1.707-.777-.659-1.494-.988-2.151-.988H70.639c-.657 0-1.374.33-2.151.988-.598.42-.897 1.048-.897 1.887l1.524 41.059c0 .599.3 1.093.897 1.482.597.39 1.314.584 2.151.584h16.584c.837 0 1.54-.195 2.107-.584.568-.39.881-.883.941-1.482zM90.54 6.02l68.846 126.5c2.092 3.773 2.032 7.546-.179 11.32a11.276 11.276 0 0 1-4.168 4.133 11.192 11.192 0 0 1-5.693 1.527H11.654c-2.032 0-3.93-.51-5.693-1.527a11.276 11.276 0 0 1-4.168-4.133c-2.211-3.774-2.271-7.547-.18-11.32L70.46 6.02a11.462 11.462 0 0 1 4.213-4.403A11.105 11.105 0 0 1 80.5 0c2.092 0 4.034.54 5.827 1.617A11.462 11.462 0 0 1 90.54 6.02z"/><path id="c" d="M91.974 123.535v-17.07c0-.839-.283-1.542-.851-2.111-.568-.57-1.24-.854-2.017-.854H71.894c-.777 0-1.449.285-2.017.854-.568.569-.851 1.272-.851 2.11v17.071c0 .839.283 1.542.851 2.111.568.57 1.24.854 2.017.854h17.212c.777 0 1.449-.285 2.017-.854.568-.569.851-1.272.851-2.11zm-.179-33.601l1.614-41.239c0-.718-.3-1.287-.897-1.707-.777-.659-1.494-.988-2.151-.988H70.639c-.657 0-1.374.33-2.151.988-.598.42-.897 1.048-.897 1.887l1.524 41.059c0 .599.3 1.093.897 1.482.597.39 1.314.584 2.151.584h16.584c.837 0 1.54-.195 2.107-.584.568-.39.881-.883.941-1.482zM90.54 6.02l68.846 126.5c2.092 3.773 2.032 7.546-.179 11.32a11.276 11.276 0 0 1-4.168 4.133 11.192 11.192 0 0 1-5.693 1.527H11.654c-2.032 0-3.93-.51-5.693-1.527a11.276 11.276 0 0 1-4.168-4.133c-2.211-3.774-2.271-7.547-.18-11.32L70.46 6.02a11.462 11.462 0 0 1 4.213-4.403A11.105 11.105 0 0 1 80.5 0c2.092 0 4.034.54 5.827 1.617A11.462 11.462 0 0 1 90.54 6.02z"/></defs><g fill="none" fill-rule="evenodd"><circle cx="256" cy="253" r="256" fill="#FDA20C"/><path fill="#000" fill-opacity=".3" fill-rule="nonzero" d="M361 98.292C361 90.982 353.978 85 345.396 85H163.604C155.022 85 148 90.981 148 98.292v296.416c0 7.31 7.022 13.292 15.604 13.292h181.792c8.582 0 15.604-5.981 15.604-13.292v-64.636c-5.462 3.323-11.703 6.646-17.945 9.305v33.399c0 1.329-.78 1.994-2.34 1.994h-172.43c-1.56 0-2.34-.665-2.34-1.994V126.87c0-1.329.78-1.993 2.34-1.993h172.43c1.56 0 2.34.664 2.34 1.993v63.113c3.901 0 8.582-.664 12.483-.664H361V98.292zM254.5 380c5.067 0 9.5 4.433 9.5 9.5s-4.433 9.5-9.5 9.5-9.5-4.433-9.5-9.5 4.433-9.5 9.5-9.5zm24.577-266.907h-47.593c-2.341 0-3.902-1.56-3.902-3.9s1.56-3.899 3.902-3.899h47.593c2.34 0 3.901 1.56 3.901 3.9 0 2.339-2.34 3.899-3.901 3.899z"/><path fill="#FFF" fill-rule="nonzero" d="M355.538 321.966c-3.9 0-8.582 0-12.483-.665v45.438c0 1.33-.78 1.996-2.34 1.996h-172.43c-1.56 0-2.34-.665-2.34-1.996V120.58c0-1.33.78-1.996 2.34-1.996h172.43c1.56 0 2.34.665 2.34 1.996v63.81c3.901 0 8.582-.666 12.483-.666H361V91.306C361 83.988 353.978 78 345.396 78H163.604C155.022 78 148 83.988 148 91.306v297.388c0 7.318 7.022 13.306 15.604 13.306h181.792c8.582 0 15.604-5.988 15.604-13.306v-66.728h-5.462zM230.703 98.871h47.594c2.34 0 3.9 1.56 3.9 3.901 0 2.34-1.56 3.902-3.12 3.902h-47.593c-2.341 0-3.902-1.561-3.902-3.902 0-2.34.78-3.901 3.121-3.901zM254.5 394c-5.067 0-9.5-4.433-9.5-9.5s4.433-9.5 9.5-9.5 9.5 4.433 9.5 9.5c0 5.7-4.433 9.5-9.5 9.5z"/><g opacity=".437" transform="translate(217 160)"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g fill="#2F3136" mask="url(#b)"><path d="M0 0H161V161H0z"/></g></g><g transform="translate(217 149)"><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><g fill="#FFF" mask="url(#d)"><path d="M0 0H161V161H0z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,38 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { PhoneField, phoneField, formPhoneField } from "@web/views/fields/phone/phone_field";
import { SendSMSButton } from '@sms/components/sms_button/sms_button';
patch(PhoneField, {
components: {
...PhoneField.components,
SendSMSButton
},
defaultProps: {
...PhoneField.defaultProps,
enableButton: true,
},
props: {
...PhoneField.props,
enableButton: { type: Boolean, optional: true },
},
});
const patchDescr = () => ({
extractProps({ options }) {
const props = super.extractProps(...arguments);
props.enableButton = options.enable_sms;
return props;
},
supportedOptions: [{
label: _t("Enable SMS"),
name: "enable_sms",
type: "boolean",
default: true,
}],
});
patch(phoneField, patchDescr());
patch(formPhoneField, patchDescr());
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.PhoneField" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_phone_content')]//a" position="after">
<t t-if="props.enableButton and props.record.data[props.name].length > 0">
<SendSMSButton t-props="props" />
</t>
</xpath>
</t>
<t t-inherit="web.FormPhoneField" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_phone_content')]" position="inside">
<t t-if="props.enableButton and props.record.data[props.name].length > 0">
<SendSMSButton t-props="props" />
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,45 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { Component, status } from "@odoo/owl";
export class SendSMSButton extends Component {
static template = "sms.SendSMSButton";
static props = ["*"];
setup() {
this.action = useService("action");
this.title = _t("Send SMS");
}
get phoneHref() {
return "sms:" + this.props.record.data[this.props.name].replace(/\s+/g, "");
}
async onClick() {
await this.props.record.save();
this.action.doAction(
{
type: "ir.actions.act_window",
target: "new",
name: this.title,
res_model: "sms.composer",
views: [[false, "form"]],
context: {
...user.context,
default_res_model: this.props.record.resModel,
default_res_id: this.props.record.resId,
default_number_field_name: this.props.name,
default_composition_mode: "comment",
},
},
{
onClose: () => {
if (status(this) === "destroyed") {
return;
}
this.props.record.load();
},
}
);
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sms.SendSMSButton">
<a
t-att-title="title"
t-att-href="phoneHref"
t-on-click.prevent.stop="onClick"
class="ms-3 d-inline-flex align-items-center o_field_phone_sms"
><i class="fa fa-mobile"></i><small class="fw-bold ms-1">SMS</small></a>
</t>
</templates>
@@ -0,0 +1,131 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import {
EmojisTextField,
emojisTextField,
} from "@mail/views/web/fields/emojis_text_field/emojis_text_field";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
/**
* SmsWidget is a widget to display a textarea (the body) and a text representing
* the number of SMS and the number of characters. This text is computed every
* time the user changes the body.
*/
export class SmsWidget extends EmojisTextField {
static template = "sms.SmsWidget";
setup() {
super.setup();
this._emojiAdded = () => this.props.record.update({ [this.props.name]: this.targetEditElement.el.value });
this.notification = useService('notification');
}
get encoding() {
return this._extractEncoding(this.props.record.data[this.props.name] || '');
}
get nbrChar() {
const content = this._getValueForSmsCounts(this.props.record.data[this.props.name] || "");
return content.length + (content.match(/\n/g) || []).length;
}
get nbrCharExplanation() {
return "";
}
get nbrSMS() {
return this._countSMS(this.nbrChar, this.encoding);
}
//--------------------------------------------------------------------------
// Private: SMS
//--------------------------------------------------------------------------
/**
* Count the number of SMS of the content
* @private
* @returns {integer} Number of SMS
*/
_countSMS(nbrChar, encoding) {
if (nbrChar === 0) {
return 0;
}
if (encoding === 'UNICODE') {
if (nbrChar <= 70) {
return 1;
}
return Math.ceil(nbrChar / 67);
}
if (nbrChar <= 160) {
return 1;
}
return Math.ceil(nbrChar / 153);
}
/**
* Extract the encoding depending on the characters in the content
* @private
* @param {String} content Content of the SMS
* @returns {String} Encoding of the content (GSM7 or UNICODE)
*/
_extractEncoding(content) {
if (String(content).match(RegExp("^[@£$¥èéùìòÇ\\nØø\\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\\\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà]*$"))) {
return 'GSM7';
}
return 'UNICODE';
}
/**
* Implement if more characters are going to be sent then those appearing in
* value, if that value is processed before being sent.
* E.g., links are converted to trackers in mass_mailing_sms.
*
* Note: goes with an explanation in nbrCharExplanation
*
* @param {String} value content to be parsed for counting extra characters
* @return string length-corrected value placeholder for the post-processed
* state
*/
_getValueForSmsCounts(value) {
return value;
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
async onBlur() {
await super.onBlur();
var content = this.props.record.data[this.props.name] || '';
if( !content.trim().length && content.length > 0) {
this.notification.add(
_t("Your SMS Text Message must include at least one non-whitespace character"),
{ type: 'danger' },
)
await this.props.record.update({ [this.props.name]: content.trim() });
}
}
/**
* @override
* @private
*/
async onInput(ev) {
super.onInput(...arguments);
await this.props.record.update({ [this.props.name]: this.targetEditElement.el.value });
}
}
export const smsWidget = {
...emojisTextField,
component: SmsWidget,
additionalClasses: [
...(emojisTextField.additionalClasses || []),
"o_field_text",
"o_field_text_emojis",
],
};
registry.category("fields").add("sms_widget", smsWidget);
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="sms.SmsWidget" t-inherit="mail.EmojisTextField" t-inherit-mode="primary">
<xpath expr="/*[last()]/*[last()]" position="after">
<div class="o_sms_container">
<span class="text-muted o_sms_count">
<t t-out="nbrChar"/> characters<t t-out="nbrCharExplanation"/>, fits in <t t-out="nbrSMS"/> SMS (<t t-out="encoding"/>)
<a href="https://iap-services.odoo.com/iap/sms/pricing" target="_blank"
title="SMS Pricing" aria-label="SMS Pricing" class="fa fa-lg fa-info-circle"/>
</span>
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,25 @@
/** @odoo-module */
import { Failure } from "@mail/core/common/failure_model";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(Failure.prototype, {
get iconSrc() {
if (this.type === "sms") {
return "/sms/static/img/sms_failure.svg";
}
return super.iconSrc;
},
get body() {
if (this.type === "sms") {
if (this.notifications.length === 1 && this.lastMessage?.thread) {
return _t("An error occurred when sending an SMS on “%(record_name)s”", {
record_name: this.lastMessage.thread.name,
});
}
return _t("An error occurred when sending an SMS");
}
return super.body;
},
});
@@ -0,0 +1,20 @@
/** @odoo-module */
import { Notification } from "@mail/core/common/notification_model";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(Notification.prototype, {
get icon() {
if (this.notification_type === "sms") {
return "fa fa-mobile";
}
return super.icon;
},
get label() {
if (this.notification_type === "sms") {
return _t("SMS");
}
return super.label;
},
});
@@ -0,0 +1,34 @@
/** @odoo-module */
import { MessagingMenu } from "@mail/core/public_web/messaging_menu";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(MessagingMenu.prototype, {
openFailureView(failure) {
if (failure.type === "email") {
return super.openFailureView(failure);
}
this.env.services.action.doAction({
name: _t("SMS Failures"),
type: "ir.actions.act_window",
view_mode: "kanban,list,form",
views: [
[false, "kanban"],
[false, "list"],
[false, "form"],
],
target: "current",
res_model: failure.resModel,
domain: [["message_has_sms_error", "=", true]],
context: { create: false },
});
this.dropdown.close();
},
getFailureNotificationName(failure) {
if (failure.type === "sms") {
return _t("SMS Failure: %(modelName)s", { modelName: failure.modelName });
}
return super.getFailureNotificationName(...arguments);
},
});
@@ -0,0 +1,19 @@
/** @odoo-module */
import { Message } from "@mail/core/common/message";
import { patch } from "@web/core/utils/patch";
patch(Message.prototype, {
onClickFailure() {
if (this.message.message_type === "sms") {
this.env.services.action.doAction("sms.sms_resend_action", {
additionalContext: {
default_mail_message_id: this.message.id,
},
});
} else {
super.onClickFailure(...arguments);
}
},
});
@@ -0,0 +1,147 @@
import {
assertSteps,
click,
contains,
start,
startServer,
step,
triggerEvents,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { defineSMSModels } from "@sms/../tests/sms_test_helpers";
import { mockService, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineSMSModels();
test("mark as read", async () => {
const pyEnv = await startServer();
const messageId = pyEnv["mail.message"].create({
message_type: "sms",
model: "res.partner",
res_id: serverState.partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "exception",
notification_type: "sms",
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem");
await triggerEvents(".o-mail-NotificationItem", ["mouseenter"], { text: "" });
await contains(".o-mail-NotificationItem [title='Mark As Read']");
await contains(".o-mail-NotificationItem-text", {
text: "An error occurred when sending an SMS on “Mitchell Admin”",
});
await click(".o-mail-NotificationItem [title='Mark As Read']");
await contains(".o-mail-NotificationItem", { count: 0 });
});
test("notifications grouped by notification_type", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
message_type: "sms",
model: "res.partner",
res_id: partnerId,
},
{
message_type: "email",
model: "res.partner",
res_id: partnerId,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "sms",
},
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "sms",
},
{
mail_message_id: messageId_2,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId_2,
notification_status: "exception",
notification_type: "email",
},
]);
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", { count: 2 });
await contains(":nth-child(1 of .o-mail-NotificationItem)", {
contains: [
[".o-mail-NotificationItem-name", { text: "Email Failure: Contact" }],
[".o-mail-NotificationItem-counter", { text: "2" }],
[".o-mail-NotificationItem-text", { text: "An error occurred when sending an email" }],
],
});
await contains(":nth-child(2 of .o-mail-NotificationItem)", {
contains: [
[".o-mail-NotificationItem-name", { text: "SMS Failure: Contact" }],
[".o-mail-NotificationItem-counter", { text: "2" }],
[".o-mail-NotificationItem-text", { text: "An error occurred when sending an SMS" }],
],
});
});
test("grouped notifications by document model", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([{}, {}]);
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
message_type: "sms",
model: "res.partner",
res_id: partnerId_1,
},
{
message_type: "sms",
model: "res.partner",
res_id: partnerId_2,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "sms",
},
{
mail_message_id: messageId_2,
notification_status: "exception",
notification_type: "sms",
},
]);
mockService("action", {
doAction(action) {
step("do_action");
expect(action.name).toBe("SMS Failures");
expect(action.type).toBe("ir.actions.act_window");
expect(action.view_mode).toBe("kanban,list,form");
expect(action.views).toEqual([
[false, "kanban"],
[false, "list"],
[false, "form"],
]);
expect(action.target).toBe("current");
expect(action.res_model).toBe("res.partner");
expect(action.domain).toEqual([["message_has_sms_error", "=", true]]);
},
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem", {
text: "SMS Failure: Contact",
contains: [".badge", { text: "2" }],
});
await assertSteps(["do_action"]);
});
@@ -0,0 +1,10 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class Partner extends models.Model {
_name = "partner";
message = fields.Char();
foo = fields.Char();
mobile = fields.Char();
partner_ids = fields.One2many({ relation: "partner" });
}
@@ -0,0 +1,7 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class Visitor extends models.Model {
_name = "visitor";
mobile = fields.Char();
}
@@ -0,0 +1,10 @@
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { Partner } from "@sms/../tests/mock_server/mock_models/partner";
import { Visitor } from "@sms/../tests/mock_server/mock_models/visitor";
import { defineModels } from "@web/../tests/web_test_helpers";
export function defineSMSModels() {
return defineModels(smsModels);
}
export const smsModels = { ...mailModels, Partner, Visitor };
@@ -0,0 +1,90 @@
import {
assertSteps,
click,
contains,
openFormView,
start,
startServer,
step,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { defineSMSModels } from "@sms/../tests/sms_test_helpers";
import { mockService } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineSMSModels();
test("Notification Processing", async () => {
const { partnerId } = await _prepareSmsNotification("process");
await start();
await openFormView("res.partner", partnerId);
await _assertContainsSmsNotification();
await _assertContainsPopoverWithIcon("fa-hourglass-half");
});
test("Notification Pending", async () => {
const { partnerId } = await _prepareSmsNotification("pending");
await start();
await openFormView("res.partner", partnerId);
await _assertContainsSmsNotification();
await _assertContainsPopoverWithIcon("fa-paper-plane-o");
});
test("Notification Sent", async () => {
const { partnerId } = await _prepareSmsNotification("sent");
await start();
await openFormView("res.partner", partnerId);
await _assertContainsPopoverWithIcon("fa-check");
});
test("Notification Error", async () => {
const { partnerId, messageId } = await _prepareSmsNotification("exception");
mockService("action", {
doAction(action, options) {
if (action?.res_model === "res.partner") {
return super.doAction(...arguments);
}
expect(action).toBe("sms.sms_resend_action");
expect(options.additionalContext.default_mail_message_id).toBe(messageId);
step("do_action");
},
});
await start();
await openFormView("res.partner", partnerId);
await _assertContainsSmsNotification();
await click(".o-mail-Message-notification");
await assertSteps(["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({
body: "not empty",
message_type: "sms",
model: "res.partner",
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: notification_status,
notification_type: "sms",
res_partner_id: partnerId,
});
return { partnerId, messageId };
};
const _assertContainsSmsNotification = async () => {
await contains(".o-mail-Message");
await contains(".o-mail-Message-notification");
await contains(".o-mail-Message-notification i");
await contains(".o-mail-Message-notification i.fa-mobile");
};
const _assertContainsPopoverWithIcon = async (iconClassName) => {
await click(".o-mail-Message-notification");
await contains(".o-mail-MessageNotificationPopover");
await contains(".o-mail-MessageNotificationPopover i");
await contains(`.o-mail-MessageNotificationPopover i.${iconClassName}`);
await contains(".o-mail-MessageNotificationPopover", { text: "Someone" });
};
@@ -0,0 +1,145 @@
import {
assertSteps,
click,
contains,
editInput,
startServer,
step,
} from "@mail/../tests/mail_test_helpers";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { queryFirst } from "@odoo/hoot-dom";
import { defineSMSModels } from "@sms/../tests/sms_test_helpers";
import { mockService, mountView, MockServer } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineSMSModels();
beforeEach(async () => {
const pyEnv = await startServer();
pyEnv["partner"].create([
{ message: "", foo: "yop", mobile: "+32494444444"},
{ message: "", foo: "bayou"},
]);
pyEnv["visitor"].create([
{ mobile: "+32494444444" },
]);
})
test("Sms button in form view", async () => {
const visitorId = MockServer.env["visitor"].search([["mobile","=","+32494444444"]])[0];
await mountView({
type: "form",
resModel: "visitor",
resId: visitorId,
mode: "readonly",
arch:
`<form>
<sheet>
<field name="mobile" widget="phone"/>
</sheet>
</form>`
});
await contains(".o_field_phone");
await contains(".o_field_phone a.o_field_phone_sms", { count: 1 });
});
test("Sms button with option enable_sms set as False", async () => {
const visitorId = MockServer.env["visitor"].search([["mobile","=","+32494444444"]])[0];
await mountView({
type: "form",
resModel: "visitor",
resId: visitorId,
mode: "readonly",
arch:
`<form>
<sheet>
<field name="mobile" widget="phone" options="{'enable_sms': false}"/>
</sheet>
</form>`
});
await contains(".o_field_phone");
await contains(".o_field_phone a.o_field_phone_sms", { count: 0 });
});
test("click on the sms button while creating a new record in a FormView", async () => {
mockService("action", {
doAction(action, options) {
step("do_action");
expect(action.type).toBe("ir.actions.act_window");
expect(action.res_model).toBe("sms.composer");
options.onClose();
},
});
const partnerId = MockServer.env["partner"].search([["foo", "=", "yop"]])[0];
await mountView({
type: "form",
resModel: "partner",
resId: partnerId,
arch:
`<form>
<sheet>
<field name="foo"/>
<field name="mobile" widget="phone"/>
</sheet>
</form>`,
});
await editInput(document.body, "[name='foo'] input", "John");
await editInput(document.body, "[name='mobile'] input", "+32494444411");
await click(".o_field_phone_sms");
expect(queryFirst("[name='foo'] input")).toHaveValue("John");
expect(queryFirst("[name='mobile'] input")).toHaveValue("+32494444411");
await assertSteps(["do_action"]);
});
test(
"click on the sms button in a FormViewDialog has no effect on the main form view",
async () => {
mockService("action", {
doAction(action, options){
step("do_action");
expect(action.type).toBe("ir.actions.act_window");
expect(action.res_model).toBe("sms.composer");
options.onClose();
},
});
const partnerId = MockServer.env["partner"].search([["foo", "=", "yop"]])[0];
await mountView({
type: "form",
resModel: "partner",
resId: partnerId,
arch:
`<form>
<sheet>
<field name="foo"/>
<field name="mobile" widget="phone"/>
<field name="partner_ids">
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>
</field>
</sheet>
</form>`,
});
await editInput(document.body, "[name='foo'] input", "John");
await editInput(document.body, "[name='mobile'] input", "+32494444411");
await click(".o-kanban-button-new");
await contains(".modal");
await editInput(document.body, ".modal .o_field_char[name='foo'] input", "Max");
await editInput(document.body, ".modal .o_field_phone[name='mobile'] input", "+324955555");
await click(":nth-child(1 of .modal) .o_field_phone_sms");
expect(queryFirst(".modal [name='foo'] input")).toHaveValue("Max");
expect(queryFirst(".modal [name='mobile'] input")).toHaveValue("+324955555");
await click(":nth-child(1 of .modal) .o_form_button_cancel");
expect(queryFirst("[name='foo'] input")).toHaveValue("John");
expect(queryFirst("[name='mobile'] input")).toHaveValue("+32494444411");
await assertSteps(["do_action"]);
}
);
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_sms_composer
from . import test_sms_template
+369
View File
@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
from contextlib import contextmanager
from freezegun import freeze_time
from unittest.mock import patch
from odoo import exceptions, tools
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.sms.models.sms_sms import SmsSms
from odoo.addons.sms.tools.sms_api import SmsApi
from odoo.tests import common
class MockSMS(common.HttpCase):
def tearDown(self):
super(MockSMS, self).tearDown()
self._clear_sms_sent()
# ------------------------------------------------------------
# UTILITY MOCKS
# ------------------------------------------------------------
@contextmanager
def mock_datetime_and_now(self, mock_dt):
""" Used when synchronization date (using env.cr.now()) is important
in addition to standard datetime mocks. Used mainly to detect sync
issues. """
with freeze_time(mock_dt), \
patch.object(self.env.cr, 'now', lambda: mock_dt):
yield
# ------------------------------------------------------------
# GATEWAY MOCK
# ------------------------------------------------------------
@contextmanager
def mockSMSGateway(self, sms_allow_unlink=False, sim_error=None, nbr_t_error=None, moderated=False, force_delivered=False):
self._clear_sms_sent()
sms_create_origin = SmsSms.create
sms_send_origin = SmsSms._send
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/2/send':
result = []
for to_send in params['messages']:
res = {'res_id': to_send['res_id'], 'state': 'delivered' if force_delivered else '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 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
)
result.append(res)
if res['state'] == 'success' or res['state'] == 'delivered':
self._sms.append({
'number': to_send['number'],
'body': to_send['content'],
})
return result
elif local_endpoint == '/api/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 or ('delivered' if force_delivered 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)
self._new_sms += res.sudo()
return res
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)
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) as _sms_api_contact_iap_mock, \
patch.object(SmsSms, 'create', autospec=True, wraps=SmsSms, side_effect=_sms_sms_create) as sms_create, \
patch.object(SmsSms, '_send', autospec=True, wraps=SmsSms, side_effect=_sms_sms_send):
self._sms_api_contact_iap_mock = _sms_api_contact_iap_mock
self._mock_sms_create = sms_create
yield
finally:
pass
def _clear_sms_sent(self):
self._sms = []
self._new_sms = self.env['sms.sms'].sudo()
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()
class SMSCase(MockSMS):
""" Main test class to use when testing SMS integrations. Contains helpers and tools related
to notification sent by SMS. """
@classmethod
def setUpClass(cls):
super().setUpClass()
# This is called to make sure that an iap_account for sms already exists or if not is created.
cls.env['iap.account'].get('sms')
def _find_sms_sent(self, partner, number):
if number is None and partner:
number = partner._phone_format()
sent_sms = next((sms for sms in self._sms if sms['number'] == number), None)
if not sent_sms:
debug_info = '\n'.join(
f"To {sms['number']}"
for sms in self._sms
)
raise AssertionError(f'sent sms not found for {partner} (number: {number})\n{debug_info}')
return sent_sms
def _find_sms_sms(self, partner, number, status, content=None):
if number is None and partner:
number = partner._phone_format()
domain = [('id', 'in', self._new_sms.ids),
('partner_id', '=', partner.id),
('number', '=', number)]
if status:
domain += [('state', '=', status)]
sms = self.env['sms.sms'].sudo().search(domain)
if len(sms) > 1 and content:
sms = sms.filtered(lambda s: content in (s.body or ""))
if not sms:
debug_info = '\n'.join(
f"To {sms.number} ({sms.partner_id}) / state {sms.state}"
for sms in self._new_sms
)
raise AssertionError(
f'sms.sms not found for {partner} (number: {number} / status {status})\n--MOCKED DATA\n{debug_info}'
)
if len(sms) > 1:
raise NotImplementedError(
f'Found {len(sms)} sms.sms for {partner} (number: {number} / status {status})'
)
return sms
def assertSMSIapSent(self, numbers, content=None):
""" Check sent SMS (to IAP, but other providers like twilio should be
mocked to fill up 'self._sms', allowing tests to pass). Order is not
checked. Each number should have received the same content. Useful to
check batch sending.
:param numbers: list of numbers;
:param content: content to check for each number;
"""
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])))
if content is not None:
self.assertIn(content, sent_sms['body'])
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.
:param partner: optional partner, used to find a ``sms.sms`` and a number
if not given;
:param number: optional number, used to find a ``sms.sms``, notably if
partner is not given;
:param failure_type: check failure type if SMS is not sent or outgoing;
:param content: if given, should be contained in sms body;
:param fields_values: optional values allowing to check directly some
values on ``sms.sms`` record;
"""
sms_sms = self._find_sms_sms(partner, number, status, content=content)
if failure_type:
self.assertEqual(sms_sms.failure_type, failure_type)
if content is not None:
self.assertIn(content, (sms_sms.body or ""))
for fname, fvalue in (fields_values or {}).items():
self.assertEqual(
sms_sms[fname], fvalue,
'SMS: expected %s for %s, got %s' % (fvalue, fname, sms_sms[fname]))
if status == 'pending':
self.assertSMSIapSent([sms_sms.number], content=content)
def assertSMSCanceled(self, partner, number, failure_type, content=None, fields_values=None):
""" Check canceled SMS. Search is done for a pair partner / number where
partner can be an empty recordset. """
self.assertSMS(partner, number, 'canceled', failure_type=failure_type, content=content, fields_values=fields_values)
def assertSMSFailed(self, partner, number, failure_type, content=None, fields_values=None):
""" Check failed SMS. Search is done for a pair partner / number where
partner can be an empty recordset. """
self.assertSMS(partner, number, 'error', failure_type=failure_type, content=content, fields_values=fields_values)
def assertSMSOutgoing(self, partner, number, content=None, fields_values=None):
""" Check outgoing SMS. Search is done for a pair partner / number where
partner can be an empty recordset. """
self.assertSMS(partner, number, 'outgoing', content=content, fields_values=fields_values)
def assertNoSMSNotification(self, messages=None):
base_domain = [('notification_type', '=', 'sms')]
if messages is not None:
base_domain += [('mail_message_id', 'in', messages.ids)]
self.assertEqual(self.env['mail.notification'].search(base_domain), self.env['mail.notification'])
self.assertEqual(self._sms, [])
def assertSMSNotification(self, recipients_info, content, messages=None, check_sms=True, sent_unlink=False,
mail_message_values=None):
""" Check content of notifications and sms.
: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 / pending / sent / exception / canceled (pending by default),
'failure_type': optional: sms_number_missing / sms_number_format / sms_credit / sms_server
}, { ... }]
:param content: SMS content
:param mail_message_values: dictionary of expected mail message fields values
"""
partners = self.env['res.partner'].concat(*list(p['partner'] for p in recipients_info if p.get('partner')))
numbers = [p['number'] for p in recipients_info if p.get('number')]
# special case of void notifications: check for False / False notifications
if not partners and not numbers:
numbers = [False]
base_domain = [
'|', ('res_partner_id', 'in', partners.ids),
'&', ('res_partner_id', '=', False), ('sms_number', 'in', numbers),
('notification_type', '=', 'sms')
]
if messages is not None:
base_domain += [('mail_message_id', 'in', messages.ids)]
notifications = self.env['mail.notification'].search(base_domain)
self.assertEqual(notifications.mapped('res_partner_id'), partners)
for recipient_info in recipients_info:
# sanity check
extra_keys = recipient_info.keys() - {
# notification
'failure_reason',
'failure_type',
'state',
# sms
'sms_fields_values',
# recipient
'number',
'partner',
'recipient_check_sms',
}
if extra_keys:
raise ValueError(f'Unsupported values: {extra_keys}')
partner = recipient_info.get('partner', self.env['res.partner'])
number = recipient_info.get('number')
state = recipient_info.get('state', 'pending')
if number is None and partner:
number = partner._phone_format()
notif = notifications.filtered(lambda n: n.res_partner_id == partner and n.sms_number == number and n.notification_status == state)
debug_info = ''
if not notif:
debug_info = '\n'.join(
f'To: {notif.sms_number} ({notif.res_partner_id}) - (State: {notif.notification_status})'
for notif in notifications
)
self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)\n%s' % (partner, number, state, debug_info))
self.assertEqual(notif.author_id, notif.mail_message_id.author_id, 'SMS: Message and notification should have the same author')
for field_name, expected_value in (mail_message_values or {}).items():
self.assertEqual(notif.mail_message_id[field_name], expected_value)
if 'failure_reason' in recipient_info:
self.assertEqual(notif.failure_reason, recipient_info['failure_reason'])
if state not in {'process', 'sent', 'ready', 'canceled', 'pending'}:
self.assertEqual(notif.failure_type, recipient_info['failure_type'])
if recipient_info.get('recipient_check_sms', check_sms):
fields_values = recipient_info.get('sms_fields_values') or {}
if state in {'process', 'pending', 'sent'}:
if sent_unlink:
self.assertSMSIapSent([number], content=content)
else:
self.assertSMS(partner, number, state, content=content, fields_values=fields_values)
elif state == 'ready':
self.assertSMS(partner, number, 'outgoing', content=content, fields_values=fields_values)
elif state == 'exception':
self.assertSMS(partner, number, 'error', failure_type=recipient_info['failure_type'], content=content, fields_values=fields_values)
elif state == 'canceled':
self.assertSMS(partner, number, 'canceled', failure_type=recipient_info['failure_type'], content=content, fields_values=fields_values)
else:
raise NotImplementedError('Not implemented')
if messages is not None:
sanitize_tags = {**tools.mail.SANITIZE_TAGS}
sanitize_tags['remove_tags'] = [*sanitize_tags['remove_tags'] + ['a']]
with patch('odoo.tools.mail.SANITIZE_TAGS', sanitize_tags):
for message in messages:
self.assertEqual(content, tools.html2plaintext(tools.html_sanitize(message.body)).rstrip('\n'))
def assertSMSLogged(self, records, body):
for record in records:
message = record.message_ids[-1]
self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note'))
self.assertEqual(message.message_type, 'sms')
self.assertEqual(tools.html2plaintext(message.body).rstrip('\n'), body)
class SMSCommon(MailCommon, SMSCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# some numbers for testing
cls.random_numbers_str = '+32456998877, 0456665544'
cls.random_numbers = cls.random_numbers_str.split(', ')
cls.random_numbers_san = [phone_validation.phone_format(number, 'BE', '32', force_format='E164') for number in cls.random_numbers]
cls.test_numbers = ['+32456010203', '0456 04 05 06', '0032456070809']
cls.test_numbers_san = [phone_validation.phone_format(number, 'BE', '32', force_format='E164') for number in cls.test_numbers]
# some numbers for mass testing
cls.mass_numbers = ['04561%s2%s3%s' % (x, x, x) for x in range(0, 10)]
cls.mass_numbers_san = [phone_validation.phone_format(number, 'BE', '32', force_format='E164') for number in cls.mass_numbers]
@classmethod
def _create_sms_template(cls, model, body=False):
return cls.env['sms.template'].create({
'name': 'Test Template',
'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})
+84
View File
@@ -0,0 +1,84 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from odoo.addons.sms.models.mail_thread import MailThread
from odoo.addons.sms.tests.common import SMSCommon, SMSCase
from odoo.tests import tagged
@tagged('at_install')
class TestSMSComposerComment(SMSCommon, SMSCase):
""" Test behaviors that are overridden when other modules
are installed (e.g., mass_mailing). In these cases,
test_mail_sms or test_mail_full should be used."""
def test_message_post_sms_vs_notification(self):
"""Check that the conversion of html to plain text does remove links
This is necessary when an SMS is sent from message_post with sms type
and not from _message_sms. In this case, it can be expected to receive html
that should be interpreted as such instead of escaped before being sent.
"""
cases = [
(
'Hello there, check this awesome <b>app</b> I found:<br/>https://odoo.com', # not a `a` link in source
'<p>Hello there, check this awesome &lt;b&gt;app&lt;/b&gt; I found:&lt;br/&gt;<a href="https://odoo.com" target="_blank" rel="noreferrer noopener">https://odoo.com</a></p>',
'Hello there, check this awesome <b>app</b> I found:<br/>https://odoo.com'
), (
'Hello there, check this awesome <b>app</b> I found:<br/><a href="https://odoo.com">Here</a>', # a link
'<p>Hello there, check this awesome &lt;b&gt;app&lt;/b&gt; I found:&lt;br/&gt;&lt;a href="<a href="https://odoo.com" target="_blank" rel="noreferrer noopener">https://odoo.com</a>"&gt;Here&lt;/a&gt;</p>',
'Hello there, check this awesome <b>app</b> I found:<br/><a href="https://odoo.com">Here</a>' # keep all information
)
]
for message_content, expected_notification_content, expected_sms_content in cases:
with self.subTest(message_content=message_content):
with self.with_user('admin'), self.mockSMSGateway():
message = self.env.user.partner_id.message_post(
body=message_content, message_type='sms', sms_numbers=['+3215228817386'])
self.assertSMSNotification(
[{'number': '+3215228817386'}], expected_sms_content, message,
mail_message_values={"body": expected_notification_content},
)
def test_message_sms_body_sms_vs_notification(self):
"""Check that the rendering of the sms notification is identical to the sms.
The only expected difference is that links are converted to be clickable.
The test verifies that MailThread._message_sms() works as expected."""
# Cases are formatted as sms text, expected notification body
cases = [
(
"Hello there, check this awesome app I found:\nhttps://odoo.com",
'<p>Hello there, check this awesome app I found:<br>'
'<a href="https://odoo.com" target="_blank" rel="noreferrer noopener">https://odoo.com</a></p>',
), (
"Hello there, check this awesome <b>app</b> I found:\nhttps://odoo.com",
# b is kept as is in notification, but link is still added as well
'<p>Hello there, check this awesome &lt;b&gt;app&lt;/b&gt; I found:<br>'
'<a href="https://odoo.com" target="_blank" rel="noreferrer noopener">https://odoo.com</a></p>',
),
(
# Here, we check that the sms sent is the sms written.
"Hello there, check this awesome <b>app</b> I found:\n*https://odoo.com*",
'<p>Hello there, check this awesome &lt;b&gt;app&lt;/b&gt; I found:<br>'
'*<a href="https://odoo.com" target="_blank" rel="noreferrer noopener">https://odoo.com</a>*</p>',
),
]
for sms_content, expected_notification_content in cases:
with self.subTest(sms_content=sms_content):
with self.with_user('admin'):
composer = self.env['sms.composer'].with_context(
active_model='res.partner', active_id=self.partner_employee).create({'body': sms_content})
_message_sms_patch = patch.object(
MailThread, '_message_sms', autospec=True, side_effect=MailThread._message_sms)
with self.mockSMSGateway(), _message_sms_patch as _patched_message_sms:
messages = composer._action_send_sms()
_patched_message_sms.assert_called() # make sure we're testing `_message_sms` too
self.assertSMSNotification(
[{'partner': self.partner_employee}], sms_content, messages,
mail_message_values={"body": expected_notification_content},
)
+138
View File
@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from odoo.tests.common import TransactionCase, users
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.exceptions import AccessError
from odoo.tests import tagged
from odoo.tools import mute_logger, convert_file
@tagged('post_install', '-at_install')
class TestSmsTemplateAccessRights(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_admin = mail_new_test_user(cls.env, login='user_system', groups='base.group_user,base.group_system')
cls.basic_user = mail_new_test_user(cls.env, login='user_employee', groups='base.group_user')
sms_enabled_models = cls.env['ir.model'].search([('is_mail_thread', '=', True), ('transient', '=', False)])
vals = []
for model in sms_enabled_models:
vals.append({
'name': 'SMS Template ' + model.name,
'body': 'Body Test',
'model_id': model.id,
})
cls.sms_templates = cls.env['sms.template'].create(vals)
cls.sms_dynamic_template = cls.env['sms.template'].sudo().create({
'body': '{{ object.name }}',
'model_id': cls.env['ir.model'].sudo().search([('model', '=', 'res.partner')]).id,
})
cls.partner = cls.env['res.partner'].create({'name': 'Test Partner'})
@users('user_employee')
@mute_logger('odoo.models.unlink')
def test_access_rights_user(self):
# Check if a member of group_user can only read on sms.template
for sms_template in self.env['sms.template'].browse(self.sms_templates.ids):
self.assertTrue(bool(sms_template.name))
with self.assertRaises(AccessError):
sms_template.write({'name': 'Update Template'})
with self.assertRaises(AccessError):
self.env['sms.template'].create({
'name': 'New SMS Template ' + sms_template.model_id.name,
'body': 'Body Test',
'model_id': sms_template.model_id.id,
})
with self.assertRaises(AccessError):
sms_template.unlink()
@users('user_system')
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_model')
def test_access_rights_system(self):
admin = self.env.ref('base.user_admin')
for sms_template in self.env['sms.template'].browse(self.sms_templates.ids):
self.assertTrue(bool(sms_template.name))
sms_template.write({'body': 'New body from admin'})
self.env['sms.template'].create({
'name': 'New SMS Template ' + sms_template.model_id.name,
'body': 'Body Test',
'model_id': sms_template.model_id.id,
})
# check admin is allowed to read all templates since he can be a member of
# other groups applying restrictions based on the model
self.assertTrue(bool(self.env['sms.template'].with_user(admin).browse(sms_template.ids).name))
sms_template.unlink()
@users('user_employee')
def test_sms_template_rendering_restricted(self):
self.env['ir.config_parameter'].sudo().set_param('mail.restrict.template.rendering', True)
self.basic_user.groups_id -= self.env.ref('mail.group_mail_template_editor')
sms_composer = self.env['sms.composer'].create({
'composition_mode': 'comment',
'template_id': self.sms_dynamic_template.id,
'res_id': self.partner.id,
'res_model': 'res.partner',
})
self.assertEqual(sms_composer.body, self.partner.name, 'Simple user should be able to render SMS template')
sms_composer.composition_mode = 'mass'
self.assertEqual(sms_composer.body, '{{ object.name }}', 'In mass mode, we should not render the template')
body = sms_composer._prepare_body_values(self.partner)[self.partner.id]
self.assertEqual(body, self.partner.name, 'In mass mode, if the user did not change the body, he should be able to render it')
sms_composer.body = 'New body: {{ 4 + 9 }}'
with self.assertRaises(AccessError, msg='User should not be able to write new inline_template code'):
sms_composer._prepare_body_values(self.partner)
@users('user_system')
def test_sms_template_rendering_unrestricted(self):
self.env['ir.config_parameter'].sudo().set_param('mail.restrict.template.rendering', True)
sms_composer = self.env['sms.composer'].create({
'composition_mode': 'comment',
'template_id': self.sms_dynamic_template.id,
'res_id': self.partner.id,
'res_model': 'res.partner',
})
body = sms_composer._prepare_body_values(self.partner)[self.partner.id]
self.assertIn(self.partner.name, body, 'Template Editor should be able to write new Jinja code')
@tagged('post_install', '-at_install')
class TestSMSTemplateReset(TransactionCase):
def _load(self, module, filepath):
# pylint: disable=no-value-for-parameter
convert_file(self.env, module='sms',
filename=filepath,
idref={}, mode='init', noupdate=False, kind='test')
def test_sms_template_reset(self):
self._load('sms', 'tests/test_sms_template.xml')
sms_template = self.env.ref('sms.sms_template_test').with_context(lang=self.env.user.lang)
sms_template.write({
'body': '<div>Hello</div>',
'name': 'SMS: SMS Template',
})
context = {'default_template_ids': sms_template.ids}
sms_template_reset = self.env['sms.template.reset'].with_context(context).create({})
reset_action = sms_template_reset.reset_template()
self.assertTrue(reset_action)
self.assertEqual(sms_template.body.strip(), Markup('<div>Hello Odoo</div>'))
# Name is not there in the data file template, so it should be set to False
self.assertFalse(sms_template.name, "Name should be set to False")
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sms_template_test" model="sms.template">
<field name="model_id" ref="base.model_res_partner"/>
<field name="body" type="html">
<div>Hello Odoo</div>
</field>
</record>
</odoo>
+2
View File
@@ -0,0 +1,2 @@
from . import sms_api
from . import sms_tools

Some files were not shown because too many files have changed in this diff Show More