Initial commit: Mail packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 4e53507711
1948 changed files with 751201 additions and 0 deletions

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import tools
from . import wizard

View file

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'SMS gateway',
'version': '2.4',
'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/ir_cron_data.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/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': {
'mail.assets_messaging': [
'sms/static/src/models/*.js',
],
'mail.assets_discuss_public': [
'sms/static/src/components/sms_button/*',
],
'web.assets_backend': [
'sms/static/src/js/fields_phone_widget.js',
'sms/static/src/components/*/*',
],
'web.qunit_suite_tests': [
'sms/static/tests/sms_widget_test.js',
'sms/static/tests/qunit_suite_tests/**/*.js',
],
},
'license': 'LGPL-3',
}

View file

@ -0,0 +1,14 @@
<?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>
<field name="numbercall">-1</field>
<field eval="False" name="doall"/>
</record>
</data></odoo>

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>

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

View file

@ -0,0 +1,144 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * sms
#
# Translators:
# Martin Trigaux, 2018
# George Tarasidis <george_tarasidis@yahoo.com>, 2018
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~11.5\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-09-21 13:17+0000\n"
"PO-Revision-Date: 2018-09-21 13:17+0000\n"
"Last-Translator: George Tarasidis <george_tarasidis@yahoo.com>, 2018\n"
"Language-Team: Greek (https://www.transifex.com/odoo/teams/41243/el/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: el\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: sms
#. openerp-web
#: code:addons/sms/static/src/js/sms_widget.js:93
#, python-format
msgid "%s chars, fits in %s SMS (%s) "
msgstr ""
#. module: sms
#: model_terms:ir.ui.view,arch_db:sms.send_sms_view_form
msgid "Cancel"
msgstr "Ακύρωση"
#. module: sms
#: model:ir.model,name:sms.model_res_partner
msgid "Contact"
msgstr "Επαφή"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__create_uid
msgid "Created by"
msgstr "Δημιουργήθηκε από"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__create_date
msgid "Created on"
msgstr "Δημιουργήθηκε στις"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_api__display_name
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__display_name
msgid "Display Name"
msgstr "Εμφάνιση Ονόματος"
#. module: sms
#: model:ir.model,name:sms.model_mail_thread
msgid "Email Thread"
msgstr "Νήμα Email"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_api__id
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__id
msgid "ID"
msgstr "Κωδικός"
#. module: sms
#: code:addons/sms/models/mail_thread.py:53
#, python-format
msgid "Insufficient credit, unable to send SMS message: %s"
msgstr ""
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_api____last_update
#: model:ir.model.fields,field_description:sms.field_sms_send_sms____last_update
msgid "Last Modified on"
msgstr "Τελευταία τροποποίηση στις"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__write_uid
msgid "Last Updated by"
msgstr "Τελευταία Ενημέρωση από"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__write_date
msgid "Last Updated on"
msgstr "Τελευταία Ενημέρωση στις"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__message
msgid "Message"
msgstr "Μήνυμα"
#. module: sms
#: code:addons/sms/wizard/send_sms.py:82
#, python-format
msgid "Missing mobile number for %s."
msgstr ""
#. module: sms
#: code:addons/sms/models/mail_thread.py:55
#, python-format
msgid "No mobile number defined, unable to send SMS message: %s"
msgstr ""
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms__recipients
msgid "Recipients"
msgstr "Αποδέκτες"
#. module: sms
#: model:ir.model,name:sms.model_sms_api
msgid "SMS API"
msgstr ""
#. module: sms
#. openerp-web
#: code:addons/sms/static/src/xml/sms_widget.xml:4
#, python-format
msgid "SMS Pricing"
msgstr ""
#. module: sms
#: code:addons/sms/models/mail_thread.py:48
#, python-format
msgid "SMS message sent: %s"
msgstr ""
#. module: sms
#: model_terms:ir.ui.view,arch_db:sms.send_sms_view_form
msgid "Send"
msgstr "Αποστολή"
#. module: sms
#: model:ir.actions.act_window,name:sms.send_sms_action
#: model:ir.actions.act_window,name:sms.send_sms_form_action
#: model:ir.model,name:sms.model_sms_send_sms
#: model_terms:ir.ui.view,arch_db:sms.partner_form_send_sms_form_view
msgid "Send SMS"
msgstr "Αποστολή SMS"
#. module: sms
#: model_terms:ir.ui.view,arch_db:sms.send_sms_view_form
msgid "Send an SMS"
msgstr "Αποστολή ενός SMS"

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

View file

@ -0,0 +1,134 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * sms
#
# Translators:
# Djordje Marjanovic <djordje_m@yahoo.com>, 2017
# Ljubisa Jovev <ljubisa.jovev@gmail.com>, 2017
# Martin Trigaux <mat@odoo.com>, 2017
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 11.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-10 11:35+0000\n"
"PO-Revision-Date: 2017-10-10 11:35+0000\n"
"Last-Translator: Martin Trigaux <mat@odoo.com>, 2017\n"
"Language-Team: Serbian (Latin) (https://www.transifex.com/odoo/teams/41243/sr%40latin/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: sr@latin\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#. module: sms
#: model_terms:ir.ui.view,arch_db:sms.send_sms_view_form
msgid "Cancel"
msgstr "Odustani"
#. module: sms
#: model:ir.model,name:sms.model_res_partner
msgid "Contact"
msgstr "Kontakt"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_create_uid
msgid "Created by"
msgstr "Kreirao"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_create_date
msgid "Created on"
msgstr "Datum kreiranja"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_api_display_name
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_display_name
msgid "Display Name"
msgstr "Naziv za prikaz"
#. module: sms
#: model:ir.model,name:sms.model_mail_thread
msgid "Email Thread"
msgstr ""
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_api_id
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_id
msgid "ID"
msgstr "ID"
#. module: sms
#: code:addons/sms/models/mail_thread.py:53
#, python-format
msgid "Insufficient credit, unable to send SMS message: %s"
msgstr ""
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_api___last_update
#: model:ir.model.fields,field_description:sms.field_sms_send_sms___last_update
msgid "Last Modified on"
msgstr "Zadnja promena"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_write_uid
msgid "Last Updated by"
msgstr "Promenio"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_write_date
msgid "Last Updated on"
msgstr "Vreme promene"
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_message
msgid "Message"
msgstr "Poruka"
#. module: sms
#: code:addons/sms/wizard/send_sms.py:48
#, python-format
msgid "Missing mobile number for %s."
msgstr ""
#. module: sms
#: code:addons/sms/models/mail_thread.py:55
#, python-format
msgid "No mobile number defined, unable to send SMS message: %s"
msgstr ""
#. module: sms
#: model:ir.model.fields,field_description:sms.field_sms_send_sms_recipients
msgid "Recipients"
msgstr ""
#. module: sms
#: code:addons/sms/models/mail_thread.py:48
#, python-format
msgid "SMS message sent: %s"
msgstr ""
#. module: sms
#: model_terms:ir.ui.view,arch_db:sms.send_sms_view_form
msgid "Send"
msgstr "Pošalji"
#. module: sms
#: model:ir.actions.act_window,name:sms.send_sms_action
#: model:ir.actions.act_window,name:sms.send_sms_form_action
msgid "Send SMS"
msgstr ""
#. module: sms
#: model_terms:ir.ui.view,arch_db:sms.send_sms_view_form
msgid "Send an SMS"
msgstr ""
#. module: sms
#: model:ir.model,name:sms.model_sms_api
msgid "sms.api"
msgstr ""
#. module: sms
#: model:ir.model,name:sms.model_sms_send_sms
msgid "sms.send_sms"
msgstr ""

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

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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 mail_thread_phone
from . import models
from . import res_partner
from . import sms_api
from . import sms_sms
from . import sms_template

View file

@ -0,0 +1,71 @@
# -*- 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 Text Message'),
], 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'), ('comment', 'Post as Message'), ('note', 'Post as Note')],
string='Send as (SMS)',
compute='_compute_sms_method',
readonly=False, store=True,
help='Choose method for SMS sending:\nSMS: mass SMS\nPost as Message: log on document\nPost as Note: mass SMS with archives')
@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'
def _check_model_coherency(self):
super()._check_model_coherency()
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"))
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

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._sms_get_number_fields() + ModelObject._sms_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._sms_get_number_fields() + ModelObject._sms_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)]

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):
if message_type != 'sms' or not (pids or records):
return super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids)
if pids is None and records:
records_pids = dict(
(record.id, record._sms_get_default_partners().ids)
for record in records
)
elif pids and records:
records_pids = dict((record.id, pids) for record in records)
else:
records_pids = {0: pids if pids else []}
recipients_data = super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids)
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

View file

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from operator import itemgetter
from odoo import exceptions, fields, models
from odoo.tools import groupby
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': 'email'})})
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()
def message_format(self, format_reply=True):
""" Override in order to retrieves data about SMS (recipient name and
SMS status)
TDE FIXME: clean the overall message_format thingy
"""
message_values = super(MailMessage, self).message_format(format_reply=format_reply)
all_sms_notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', 'in', [r['id'] for r in message_values]),
('notification_type', '=', 'sms')
])
msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo())
for notif in all_sms_notifications:
msgid_to_notif[notif.mail_message_id.id] += notif
for message in message_values:
customer_sms_data = [(notif.id, notif.res_partner_id.display_name or notif.sms_number, notif.notification_status) for notif in msgid_to_notif.get(message['id'], [])]
message['sms_ids'] = customer_sms_data
return message_values

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailNotification(models.Model):
_inherit = 'mail.notification'
notification_type = fields.Selection(selection_add=[
('sms', 'SMS')
], ondelete={'sms': 'cascade'})
sms_id = fields.Many2one('sms.sms', string='SMS', index='btree_not_null', ondelete='set null')
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_server', 'Server Error'),
('sms_acc', 'Unregistered Account')
])

View file

@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, models, fields
from odoo.addons.phone_validation.tools import phone_validation
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):
recipients_data = super(MailThread, self)._notify_thread(message, msg_vals=msg_vals, **kwargs)
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
sanitize_res = phone_validation.phone_sanitize_numbers_w_record([number], partner)[number]
number = sanitize_res['sanitized'] or number
sms_create_vals.append(dict(
sms_base_vals,
partner_id=partner.id,
number=number
))
# notify from additional numbers
if sms_numbers:
sanitized = phone_validation.phone_sanitize_numbers_w_record(sms_numbers, self)
tocreate_numbers = [
value['sanitized'] or original
for original, value in sanitized.items()
]
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', '=', message.id)
])
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': sms.id,
'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': sms.id,
'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
@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

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PhoneMixin(models.AbstractModel):
_inherit = 'mail.thread.phone'
def _sms_get_number_fields(self):
""" Add fields coming from mail.thread.phone implementation. """
phone_fields = self._phone_get_number_fields()
sms_fields = super(PhoneMixin, self)._sms_get_number_fields()
for fname in (f for f in sms_fields if f not in phone_fields):
phone_fields.append(fname)
return phone_fields

View file

@ -0,0 +1,117 @@
from odoo import models
from odoo.addons.phone_validation.tools import phone_validation
class BaseModel(models.AbstractModel):
_inherit = 'base'
def _sms_get_partner_fields(self):
""" This method returns the fields to use to find the contact to link
whensending an SMS. Having partner is not necessary, having only phone
number fields is possible. However it gives more flexibility to
notifications management when having partners. """
fields = []
if hasattr(self, 'partner_id'):
fields.append('partner_id')
if hasattr(self, 'partner_ids'):
fields.append('partner_ids')
return fields
def _sms_get_default_partners(self):
""" This method will likely need to be overridden by inherited models.
:returns partners: recordset of res.partner
"""
partners = self.env['res.partner']
for fname in self._sms_get_partner_fields():
partners = partners.union(*self.mapped(fname)) # ensure ordering
return partners
def _sms_get_number_fields(self):
""" This method returns the fields to use to find the number to use to
send an SMS on a record. """
if 'mobile' in self:
return ['mobile']
return []
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 ``_sms_get_number_fields``;
:param partner_fallback: if no value found in the record, check its customer
values based on ``_sms_get_default_partners``;
:return dict: record.id: {
'partner': a res.partner recordset that is the customer (void or singleton)
linked to the recipient. See ``_sms_get_default_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 ``_sms_get_number_fields``);
} for each record in self
"""
result = dict.fromkeys(self.ids, False)
tocheck_fields = [force_field] if force_field else self._sms_get_number_fields()
for record in self:
all_numbers = [record[fname] for fname in tocheck_fields if fname in record]
all_partners = record._sms_get_default_partners()
valid_number = False
for fname in [f for f in tocheck_fields if f in record]:
valid_number = phone_validation.phone_sanitize_numbers_w_record([record[fname]], record)[record[fname]]['sanitized']
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']._sms_get_number_fields():
valid_number = phone_validation.phone_sanitize_numbers_w_record([partner[fname]], record)[partner[fname]]['sanitized']
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

View file

@ -0,0 +1,20 @@
# -*- 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']
def _sms_get_default_partners(self):
""" Override of mail.thread method.
SMS recipients on partners are the partners themselves.
"""
return self
def _phone_get_number_fields(self):
""" This method returns the fields to use to find the number to use to
send an SMS on a record. """
return ['mobile', 'phone']

View file

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, exceptions, models
from odoo.addons.iap.tools import iap_tools
DEFAULT_ENDPOINT = 'https://iap-sms.odoo.com'
class SmsApi(models.AbstractModel):
_name = 'sms.api'
_description = 'SMS API'
@api.model
def _contact_iap(self, local_endpoint, params):
if not self.env.registry.ready: # Don't reach IAP servers during module installation
raise exceptions.AccessError("Unavailable during module installation.")
account = self.env['iap.account'].get('sms')
params['account_token'] = account.account_token
endpoint = self.env['ir.config_parameter'].sudo().get_param('sms.endpoint', DEFAULT_ENDPOINT)
# TODO PRO, the default timeout is 15, do we have to increase it ?
return iap_tools.iap_jsonrpc(endpoint + local_endpoint, params=params)
@api.model
def _send_sms(self, numbers, message):
""" Send a single message to several numbers
:param numbers: list of E164 formatted phone numbers
:param message: content to send
:raises ? TDE FIXME
"""
params = {
'numbers': numbers,
'message': message,
}
return self._contact_iap('/iap/message_send', params)
@api.model
def _send_sms_batch(self, messages):
""" Send SMS using IAP in batch mode
:param messages: list of SMS to send, structured as dict [{
'res_id': integer: ID of sms.sms,
'number': string: E164 formatted phone number,
'content': string: content to send
}]
:return: return of /iap/sms/1/send controller which is a list of dict [{
'res_id': integer: ID of sms.sms,
'state': string: 'insufficient_credit' or 'wrong_number_format' or 'success',
'credit': integer: number of credits spent to send this SMS,
}]
:raises: normally none
"""
params = {
'messages': messages
}
return self._contact_iap('/iap/sms/2/send', params)
@api.model
def _get_sms_api_error_messages(self):
""" Returns a dict containing the error message to display for every known error 'state'
resulting from the '_send_sms_batch' method.
We prefer a dict instead of a message-per-error-state based method so we only call
the 'get_credits_url' once, to avoid extra RPC calls. """
buy_credits_url = self.sudo().env['iap.account'].get_credits_url(service_name='sms')
buy_credits = '<a href="%s" target="_blank">%s</a>' % (
buy_credits_url,
_('Buy credits.')
)
return {
'unregistered': _("You don't have an eligible IAP account."),
'insufficient_credit': ' '.join([_('You don\'t have enough credits on your IAP account.'), buy_credits]),
'wrong_number_format': _("The number you're trying to reach is not correctly formatted."),
}

View file

@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import threading
from odoo import api, fields, models, tools, _
_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': 'sent',
'insufficient_credit': 'sms_credit',
'wrong_number_format': 'sms_number_format',
'server_error': 'sms_server',
'unregistered': 'sms_acc'
}
number = fields.Char('Number')
body = fields.Text()
partner_id = fields.Many2one('res.partner', 'Customer')
mail_message_id = fields.Many2one('mail.message', index=True)
state = fields.Selection([
('outgoing', 'In Queue'),
('sent', 'Sent'),
('error', 'Error'),
('canceled', 'Canceled')
], 'SMS Status', readonly=True, copy=False, default='outgoing', required=True)
failure_type = fields.Selection([
('sms_number_missing', 'Missing Number'),
('sms_number_format', 'Wrong Number Format'),
('sms_credit', 'Insufficient Credit'),
('sms_server', 'Server Error'),
('sms_acc', 'Unregistered Account'),
# mass mode specific codes
('sms_blacklist', 'Blacklisted'),
('sms_duplicate', 'Duplicate'),
('sms_optout', 'Opted Out'),
], copy=False)
def action_set_canceled(self):
self.state = 'canceled'
notifications = self.env['mail.notification'].sudo().search([
('sms_id', 'in', self.ids),
# sent is sent -> cannot reset
('notification_status', 'not in', ['canceled', 'sent']),
])
if notifications:
notifications.write({'notification_status': 'canceled'})
if not self._context.get('sms_skip_msg_notification', False):
notifications.mail_message_id._notify_message_notification_update()
def action_set_error(self, failure_type):
self.state = 'error'
self.failure_type = failure_type
notifications = self.env['mail.notification'].sudo().search([
('sms_id', 'in', self.ids),
# sent can be set to error due to IAP feedback
('notification_status', '!=', 'exception'),
])
if notifications:
notifications.write({'notification_status': 'exception', 'failure_type': failure_type})
if not self._context.get('sms_skip_msg_notification', False):
notifications.mail_message_id._notify_message_notification_update()
def action_set_outgoing(self):
self.write({
'state': 'outgoing',
'failure_type': False
})
notifications = self.env['mail.notification'].sudo().search([
('sms_id', 'in', self.ids),
# sent is sent -> cannot reset
('notification_status', 'not in', ['ready', 'sent']),
])
if notifications:
notifications.write({'notification_status': 'ready', 'failure_type': False})
if not self._context.get('sms_skip_msg_notification', False):
notifications.mail_message_id._notify_message_notification_update()
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;
"""
self = self.filtered(lambda sms: sms.state == 'outgoing')
for batch_ids in self._split_batch():
self.browse(batch_ids)._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 resend_failed(self):
sms_to_send = self.filtered(lambda sms: sms.state == 'error')
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 = _('%s out of the %s selected SMS Text Messages have successfully been resent.', success_sms, 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')]
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 _split_batch(self):
batch_size = int(self.env['ir.config_parameter'].sudo().get_param('sms.session.batch.size', 500))
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):
""" This method tries to send SMS after checking the number (presence and
formatting). """
iap_data = [{
'res_id': record.id,
'number': record.number,
'content': record.body,
} for record in self]
try:
iap_results = self.env['sms.api']._send_sms_batch(iap_data)
except Exception as e:
_logger.info('Sent batch %s SMS: %s: failed with exception %s', len(self.ids), self.ids, e)
if raise_exception:
raise
self._postprocess_iap_sent_sms(
[{'res_id': sms.id, 'state': 'server_error'} for sms in self],
unlink_failed=unlink_failed, unlink_sent=unlink_sent)
else:
_logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, iap_results)
self._postprocess_iap_sent_sms(iap_results, unlink_failed=unlink_failed, unlink_sent=unlink_sent)
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, unlink_failed=False, unlink_sent=True):
todelete_sms_ids = []
if unlink_failed:
todelete_sms_ids += [item['res_id'] for item in iap_results if item['state'] != 'success']
if unlink_sent:
todelete_sms_ids += [item['res_id'] for item in iap_results if item['state'] == 'success']
for state in self.IAP_TO_SMS_STATE.keys():
sms_ids = [item['res_id'] for item in iap_results if item['state'] == state]
if sms_ids:
if state != 'success' and not unlink_failed:
self.env['sms.sms'].sudo().browse(sms_ids).write({
'state': 'error',
'failure_type': self.IAP_TO_SMS_STATE[state],
})
if state == 'success' and not unlink_sent:
self.env['sms.sms'].sudo().browse(sms_ids).write({
'state': 'sent',
'failure_type': False,
})
notifications = self.env['mail.notification'].sudo().search([
('notification_type', '=', 'sms'),
('sms_id', 'in', sms_ids),
('notification_status', 'not in', ('sent', 'canceled')),
])
if notifications:
notifications.write({
'notification_status': 'sent' if state == 'success' else 'exception',
'failure_type': self.IAP_TO_SMS_STATE[state] if state != 'success' else False,
'failure_reason': failure_reason if failure_reason else False,
})
self.mail_message_id._notify_message_notification_update()
if todelete_sms_ids:
self.browse(todelete_sms_ids).sudo().unlink()

View file

@ -0,0 +1,78 @@
# -*- 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(SMSTemplate, self).default_get(fields)
if not fields or '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
# ------------------------------------------------------------
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
default = dict(default or {},
name=_("%s (copy)", self.name))
return super(SMSTemplate, self).copy(default=default)
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

View file

@ -0,0 +1,11 @@
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_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
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_composer access.sms.composer model_sms_composer base.group_user 1 1 1 0
8 access_sms_resend_recipient access.sms.resend.recipient model_sms_resend_recipient base.group_user 1 1 1 0
9 access_sms_resend access.sms.resend model_sms_resend base.group_user 1 1 1 0
10 access_sms_template_preview access.sms.template.preview model_sms_template_preview base.group_user 1 1 1 0
11 access_sms_template_reset access.sms.template.reset model_sms_template_reset mail.group_mail_template_editor 1 1 1 1

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: 3.8 KiB

View file

@ -0,0 +1,41 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 70 70">
<defs>
<mask id="mask" x="0" y="0" width="70" height="70" maskUnits="userSpaceOnUse">
<g id="b">
<path id="a" d="M4,0H65c4,0,5,1,5,5V65c0,4-1,5-5,5H4c-3,0-4-1-4-5V5C0,1,1,0,4,0Z" fill="#fff" fill-rule="evenodd"/>
</g>
</mask>
<linearGradient id="linear-gradient" x1="-1172.36" y1="477.94" x2="-1173.36" y2="476.94" gradientTransform="matrix(70, 0, 0, -70, 82134.99, 33455.73)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7cc098"/>
<stop offset="1" stop-color="#5f8a71"/>
</linearGradient>
</defs>
<g mask="url(#mask)">
<g>
<path d="M0,0H70V70H0Z" fill-rule="evenodd" fill="url(#linear-gradient)"/>
<path d="M4,1H65c2.67,0,4.33.67,5,2V0H0V3C.67,1.67,2,1,4,1Z" fill="#fff" fill-opacity="0.38" fill-rule="evenodd"/>
<path d="M4,69H65c2.67,0,4.33-1,5-3v4H0V66A3.92,3.92,0,0,0,4,69Z" fill-opacity="0.38" fill-rule="evenodd"/>
<path d="M4,69a3.66,3.66,0,0,1-4-4V34.17L20.65,13.52l24.78-.66,2,3.78v9.44l-6.11,6.11,2.41,2.11-1.09,1.09,1.31,2L47.32,34l1.52,0,3.8-3.83,1.87,12-7.08,7.1-.56,7.6L34.81,69Z" fill-opacity="0.15" fill-rule="evenodd"/>
<g>
<g opacity="0.4" style="isolation: isolate">
<path d="M21.59,39.06,23,39a2.29,2.29,0,0,0,.49,1,1.5,1.5,0,0,0,1.05.33,1.87,1.87,0,0,0,1-.27.87.87,0,0,0,.33-.66A.64.64,0,0,0,25.7,39a1.88,1.88,0,0,0-.49-.33l-1.15-.33a4.49,4.49,0,0,1-1.59-.71,1.82,1.82,0,0,1-.66-1.42,1.52,1.52,0,0,1,.33-1,1.83,1.83,0,0,1,.88-.71,3.31,3.31,0,0,1,1.37-.22,3.2,3.2,0,0,1,2,.6,2.05,2.05,0,0,1,.72,1.54l-1.43,0a1.3,1.3,0,0,0-.38-.77,1.44,1.44,0,0,0-.88-.22,1.73,1.73,0,0,0-1,.28.51.51,0,0,0-.22.44.52.52,0,0,0,.22.44,3.47,3.47,0,0,0,1.32.49,7.23,7.23,0,0,1,1.53.49,2,2,0,0,1,.77.71,2.34,2.34,0,0,1,.27,1.16A2.3,2.3,0,0,1,27,40.6a2.08,2.08,0,0,1-.94.76,4.1,4.1,0,0,1-1.53.28A3.3,3.3,0,0,1,22.47,41,3.1,3.1,0,0,1,21.59,39.06Z"/>
<path d="M28.39,41.36V34.29h2.13l1.26,4.83L33,34.29h2.14v7.07H33.87V35.83L32.5,41.36H31.13l-1.37-5.53v5.53Z"/>
<path d="M36.17,39.06,37.54,39A2.29,2.29,0,0,0,38,40a1.56,1.56,0,0,0,1,.28,1.77,1.77,0,0,0,1-.28.83.83,0,0,0,.33-.65.61.61,0,0,0-.16-.44,2.07,2.07,0,0,0-.49-.33c-.17-.06-.55-.17-1.16-.33A3.37,3.37,0,0,1,37,37.53a1.88,1.88,0,0,1-.65-1.43,1.51,1.51,0,0,1,.33-1,1.93,1.93,0,0,1,.87-.72A3.52,3.52,0,0,1,39,34.18a3.15,3.15,0,0,1,2,.61,2,2,0,0,1,.71,1.53l-1.42.06a1.25,1.25,0,0,0-.39-.77,1.33,1.33,0,0,0-.87-.22,1.81,1.81,0,0,0-1,.27.52.52,0,0,0-.22.44.51.51,0,0,0,.22.44,3.09,3.09,0,0,0,1.32.49,7.13,7.13,0,0,1,1.53.5,2,2,0,0,1,.77.71,2.44,2.44,0,0,1,.27,1.15,2.29,2.29,0,0,1-.33,1.15,1.8,1.8,0,0,1-.93.77,4.36,4.36,0,0,1-1.53.27A3.42,3.42,0,0,1,37,41,3,3,0,0,1,36.17,39.06Z"/>
<path d="M57.14,37.2l-4.55-4.93a1.37,1.37,0,0,0-2.36,1.06v2.74H45.3v4h4.93v3a1.37,1.37,0,0,0,2.36,1.06l4.55-4.93A1.54,1.54,0,0,0,57.14,37.2Z"/>
<path d="M18.09,36.07H10a1.78,1.78,0,0,0-1.69,1.34,2,2,0,0,0,1.62,2.66h8.18Z"/>
<path d="M43.41,14.88H20.11a2,2,0,0,0-2,2V28.09h2.3V21.28a.27.27,0,0,1,.3-.3h22.1a.27.27,0,0,1,.3.3v6.81h2.3V16.88A2,2,0,0,0,43.41,14.88Zm-8.5,4.7h-6.1a.47.47,0,0,1-.5-.5c0-.3.1-.5.4-.5h6.1a.47.47,0,0,1,.5.5A.46.46,0,0,1,34.91,19.58Z"/>
<path d="M43.11,47.38v6.9a.27.27,0,0,1-.3.3H20.71a.27.27,0,0,1-.3-.3v-6.9h-2.3v10.2a2,2,0,0,0,2,2h23.3a2,2,0,0,0,2-2V47.38Zm-11.3,11.1a1.5,1.5,0,1,1,1.5-1.5A1.47,1.47,0,0,1,31.81,58.48Z"/>
</g>
<g style="isolation: isolate">
<path d="M23.61,37,25,36.93a2.3,2.3,0,0,0,.5,1,1.48,1.48,0,0,0,1,.33,1.85,1.85,0,0,0,1-.27.87.87,0,0,0,.33-.66.62.62,0,0,0-.17-.44,1.88,1.88,0,0,0-.49-.33l-1.15-.33a5,5,0,0,1-1.59-.71,1.84,1.84,0,0,1-.66-1.43,1.51,1.51,0,0,1,.33-1,1.87,1.87,0,0,1,.88-.71,3.31,3.31,0,0,1,1.37-.22,3.23,3.23,0,0,1,2,.6,2.06,2.06,0,0,1,.71,1.53l-1.43.06a1.27,1.27,0,0,0-.38-.77,1.42,1.42,0,0,0-.88-.22,1.7,1.7,0,0,0-1,.28.48.48,0,0,0-.22.43.48.48,0,0,0,.22.44,3.13,3.13,0,0,0,1.31.5,6.81,6.81,0,0,1,1.54.49,1.87,1.87,0,0,1,.76.71,2.33,2.33,0,0,1,.28,1.15A2.27,2.27,0,0,1,29,38.57a2.13,2.13,0,0,1-.93.77,4.37,4.37,0,0,1-1.54.27,3.32,3.32,0,0,1-2.08-.6A3.15,3.15,0,0,1,23.61,37Z" fill="#fff"/>
<path d="M30.41,39.34V32.27h2.14l1.26,4.82,1.26-4.82H37.2v7.07H35.89V33.81l-1.37,5.53H33.15l-1.37-5.53v5.53Z" fill="#fff"/>
<path d="M38.19,37l1.37-.11a2.4,2.4,0,0,0,.49,1,1.57,1.57,0,0,0,1.05.27,1.87,1.87,0,0,0,1-.27.85.85,0,0,0,.33-.66.64.64,0,0,0-.17-.44,1.66,1.66,0,0,0-.49-.32c-.17-.06-.55-.17-1.15-.33a3.27,3.27,0,0,1-1.59-.72,1.82,1.82,0,0,1-.66-1.42,1.55,1.55,0,0,1,.33-1,1.83,1.83,0,0,1,.88-.71A3.48,3.48,0,0,1,41,32.16a3.2,3.2,0,0,1,2,.6,2.07,2.07,0,0,1,.72,1.54l-1.43.05a1.24,1.24,0,0,0-.38-.76,1.37,1.37,0,0,0-.88-.22,1.81,1.81,0,0,0-1,.27.5.5,0,0,0-.21.44.51.51,0,0,0,.21.44,3.3,3.3,0,0,0,1.32.49,7.64,7.64,0,0,1,1.53.49,1.92,1.92,0,0,1,.77.72,2.32,2.32,0,0,1-.05,2.3,1.89,1.89,0,0,1-.93.77,4.42,4.42,0,0,1-1.54.27,3.36,3.36,0,0,1-2.08-.6A3,3,0,0,1,38.19,37Z" fill="#fff"/>
<path d="M59.16,35.18l-4.54-4.93a1.37,1.37,0,0,0-2.36,1.06V34H47.32v4h4.94v3a1.37,1.37,0,0,0,2.36,1.06l4.54-4.93A1.54,1.54,0,0,0,59.16,35.18Z" fill="#fff"/>
<path d="M20.11,34H12a1.79,1.79,0,0,0-1.7,1.35A2,2,0,0,0,11.94,38h8.17Z" fill="#fff"/>
<path d="M45.43,12.86H22.13a2,2,0,0,0-2,2V26.07h2.3V19.26a.27.27,0,0,1,.3-.3h22.1a.27.27,0,0,1,.3.3v6.81h2.3V14.86A2,2,0,0,0,45.43,12.86Zm-8.5,4.7h-6.1a.47.47,0,0,1-.5-.5c0-.3.1-.5.4-.5h6.1a.47.47,0,0,1,.5.5A.46.46,0,0,1,36.93,17.56Z" fill="#fff"/>
<path d="M45.13,45.35v6.91a.27.27,0,0,1-.3.3H22.73a.27.27,0,0,1-.3-.3V45.35h-2.3V55.56a2,2,0,0,0,2,2h23.3a2,2,0,0,0,2-2V45.35ZM33.83,56.46a1.5,1.5,0,1,1,1.5-1.5A1.47,1.47,0,0,1,33.83,56.46Z" fill="#fff"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

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

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.NotificationGroup" t-inherit-mode="extension">
<xpath expr="//*[hasclass('o_NotificationGroup_inlineText')]" position="inside">
<t t-if="notificationGroupView.notificationGroup.notification_type === 'sms'">
An error occurred when sending an SMS.
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,26 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { PhoneField } from "@web/views/fields/phone/phone_field";
import { SendSMSButton } from '@sms/components/sms_button/sms_button';
patch(PhoneField, "sms.PhoneField", {
components: {
...PhoneField.components,
SendSMSButton
},
defaultProps: {
...PhoneField.defaultProps,
enableButton: true,
},
props: {
...PhoneField.props,
enableButton: { type: Boolean, optional: true },
},
extractProps: ({ attrs }) => {
return {
enableButton: attrs.options.enable_sms,
placeholder: attrs.placeholder,
};
},
});

View file

@ -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.value.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.value.length > 0">
<SendSMSButton t-props="props" />
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,41 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
const { Component , status } = owl;
export class SendSMSButton extends Component {
setup() {
this.action = useService("action");
this.user = useService("user");
this.title = this.env._t("Send SMS Text Message");
}
get phoneHref() {
return "sms:" + this.props.value.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: {
...this.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") {
this.props.record.load();
this.props.record.model.notify();
}
},
});
}
};
SendSMSButton.template = "sms.SendSMSButton";

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sms.SendSMSButton" owl="1">
<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>

View file

@ -0,0 +1,141 @@
/** @odoo-module **/
import basic_fields from 'web.basic_fields';
import { patch } from "@web/core/utils/patch";
import { EmojisTextField} from '@mail/views/fields/emojis_text_field/emojis_text_field';
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
const DynamicPlaceholderFieldMixin = basic_fields.DynamicPlaceholderFieldMixin;
/**
* 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 {
setup() {
super.setup();
this.notification = useService('notification');
}
get encoding() {
return this._extractEncoding(this.props.value || '');
}
get nbrChar() {
const content = this._getValueForSmsCounts(this.props.value || '');
return content.length + (content.match(/\n/g) || []).length;
}
get nbrCharExplanation() {
return '';
}
get nbrSMS() {
return this._countSMS(this.nbrChar, this.encoding);
}
/**
* Open a Model Field Selector in order to select fields
* and create a dynamic placeholder string with or without
* a default text value.
*
* @public
* @param {String} baseModel
* @param {Array} chain
*
*/
async openDynamicPlaceholder(baseModel, chain = []) {
const modelSelector = await this._openNewModelSelector(baseModel, chain);
modelSelector.$el.css('margin-top', 4);
}
//--------------------------------------------------------------------------
// 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() {
var content = this.props.value || '';
if( !content.trim().length && content.length > 0) {
this.notification.add(
this.env._t("Your SMS Text Message must include at least one non-whitespace character"),
{ type: 'danger' },
)
await this.props.update(content.trim());
}
}
/**
* @override
* @private
*/
async onInput(ev) {
await this.props.update(this.targetEditElement.el.value);
super.onInput(...arguments);
const key = ev.originalEvent ? ev.originalEvent.data : '';
if (this.props.dynamicPlaceholder && key === this.DYNAMIC_PLACEHOLDER_TRIGGER_KEY) {
const baseModel = this.recordData && this.recordData.mailing_model_real ? this.recordData.mailing_model_real : undefined;
if (baseModel) {
this.openDynamicPlaceholder(baseModel);
}
}
}
};
SmsWidget.template = 'sms.SmsWidget';
SmsWidget.additionalClasses = [...(EmojisTextField.additionalClasses || []), 'o_field_text'];
patch(SmsWidget.prototype, 'sms_widget_dynamic_placeholder_field_mixin', DynamicPlaceholderFieldMixin);
registry.category("fields").add("sms_widget", SmsWidget);

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="sms.SmsWidget" t-inherit="mail.EmojisTextField" t-inherit-mode="primary" owl="1">
<xpath expr="//textarea[1]" position="attributes">
<attribute name="t-on-blur">onBlur</attribute>
</xpath>
<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>

View file

@ -0,0 +1,99 @@
odoo.define('sms.fields', function (require) {
"use strict";
var basic_fields = require('web.basic_fields');
var core = require('web.core');
var session = require('web.session');
var _t = core._t;
/**
* Override of FieldPhone to add a button calling SMS composer if option activated (default)
*/
var Phone = basic_fields.FieldPhone;
Phone.include({
/**
* By default, enable_sms is activated
*
* @override
*/
init() {
this._super.apply(this, arguments);
this.enableSMS = 'enable_sms' in this.attrs.options ? this.attrs.options.enable_sms : true;
// reinject in nodeOptions (and thus in this.attrs) to signal the property
this.attrs.options.enable_sms = this.enableSMS;
},
/**
* When the send SMS button is displayed, $el becomes a div wrapping
* the original links.
* This method makes sure we always focus the phone number
*
* @override
*/
getFocusableElement() {
if (this.enableSMS && this.mode === 'readonly') {
return this.$el.filter('.' + this.className).find('a');
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Open SMS composer wizard
*
* @private
*/
_onClickSMS: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var context = session.user_context;
context = _.extend({}, context, {
default_res_model: this.model,
default_res_id: parseInt(this.res_id),
default_number_field_name: this.name,
default_composition_mode: 'comment',
});
var self = this;
return this.do_action({
title: _t('Send SMS Text Message'),
type: 'ir.actions.act_window',
res_model: 'sms.composer',
target: 'new',
views: [[false, 'form']],
context: context,
}, {
on_close: function () {
self.trigger_up('reload');
}});
},
/**
* Add a button to call the composer wizard
*
* @override
* @private
*/
_renderReadonly: function () {
var def = this._super.apply(this, arguments);
if (this.enableSMS && this.value) {
var $composerButton = $('<a>', {
title: _t('Send SMS Text Message'),
href: '',
class: 'ms-3 d-inline-flex align-items-center o_field_phone_sms',
html: $('<small>', {class: 'fw-bold ms-1', html: 'SMS'}),
});
$composerButton.prepend($('<i>', {class: 'fa fa-mobile'}));
$composerButton.on('click', this._onClickSMS.bind(this));
this.$el = this.$el.add($composerButton);
}
return def;
},
});
return Phone;
});

View file

@ -0,0 +1,26 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
registerPatch({
name: 'Message',
recordMethods: {
/**
* @override
*/
openResendAction() {
if (this.message_type === 'sms') {
this.env.services.action.doAction(
'sms.sms_resend_action',
{
additionalContext: {
default_mail_message_id: this.id,
},
},
);
} else {
this._super(...arguments);
}
},
},
});

View file

@ -0,0 +1,41 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
registerPatch({
name: 'MessageView',
fields: {
failureNotificationIconClassName: {
compute() {
if (this.message && this.message.message_type === 'sms') {
return 'fa fa-mobile';
}
return this._super();
},
},
failureNotificationIconLabel: {
compute() {
if (this.message && this.message.message_type === 'sms') {
return this.env._t("SMS");
}
return this._super();
},
},
notificationIconClassName: {
compute() {
if (this.message && this.message.message_type === 'sms') {
return 'fa fa-mobile';
}
return this._super();
},
},
notificationIconLabel: {
compute() {
if (this.message && this.message.message_type === 'sms') {
return this.env._t("SMS");
}
return this._super();
},
},
},
});

View file

@ -0,0 +1,32 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
registerPatch({
name: 'NotificationGroup',
recordMethods: {
/**
* @override
*/
_openDocuments() {
if (this.notification_type !== 'sms') {
return this._super(...arguments);
}
this.env.services.action.doAction({
name: this.env._t("SMS Failures"),
type: 'ir.actions.act_window',
view_mode: 'kanban,list,form',
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
target: 'current',
res_model: this.res_model,
domain: [['message_has_sms_error', '=', true]],
context: { create: false },
});
if (this.messaging.device.isSmall) {
// messaging menu has a higher z-index than views so it must
// be closed to ensure the visibility of the view
this.messaging.messagingMenu.close();
}
},
},
});

View file

@ -0,0 +1,17 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
registerPatch({
name: 'NotificationGroupView',
fields: {
imageSrc: {
compute() {
if (this.notificationGroup.notification_type === 'sms') {
return '/sms/static/img/sms_failure.svg';
}
return this._super();
},
},
},
});

View file

@ -0,0 +1,159 @@
/** @odoo-module **/
import { makeDeferred } from '@mail/utils/deferred';
import {
afterNextRender,
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
import { patchWithCleanup } from '@web/../tests/helpers/utils';
QUnit.module('sms', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('message_tests.js');
QUnit.test('Notification Sent', async function (assert) {
assert.expect(9);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Someone", partner_share: true });
const mailMessageId1 = pyEnv['mail.message'].create({
body: 'not empty',
message_type: 'sms',
model: 'res.partner',
res_id: resPartnerId1,
});
pyEnv['mail.notification'].create({
mail_message_id: mailMessageId1,
notification_status: 'sent',
notification_type: 'sms',
res_partner_id: resPartnerId1,
});
const { openFormView } = await start();
await openFormView({
res_id: resPartnerId1,
res_model: 'res.partner',
});
assert.containsOnce(
document.body,
'.o_Message',
"should display a message component"
);
assert.containsOnce(
document.body,
'.o_Message_notificationIconClickable',
"should display the notification icon container"
);
assert.containsOnce(
document.body,
'.o_Message_notificationIcon',
"should display the notification icon"
);
assert.hasClass(
document.querySelector('.o_Message_notificationIcon'),
'fa-mobile',
"icon should represent sms"
);
await afterNextRender(() => {
document.querySelector('.o_Message_notificationIconClickable').click();
});
assert.containsOnce(
document.body,
'.o_MessageNotificationPopoverContent',
"notification popover should be open"
);
assert.containsOnce(
document.body,
'.o_MessageNotificationPopoverContent_notificationIcon',
"popover should have one icon"
);
assert.hasClass(
document.querySelector('.o_MessageNotificationPopoverContent_notificationIcon'),
'fa-check',
"popover should have the sent icon"
);
assert.containsOnce(
document.body,
'.o_MessageNotificationPopoverContent_notificationPartnerName',
"popover should have the partner name"
);
assert.strictEqual(
document.querySelector('.o_MessageNotificationPopoverContent_notificationPartnerName').textContent.trim(),
"Someone",
"partner name should be correct"
);
});
QUnit.test('Notification Error', async function (assert) {
assert.expect(8);
const openResendActionDef = makeDeferred();
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Someone", partner_share: true });
const mailMessageId1 = pyEnv['mail.message'].create({
body: 'not empty',
message_type: 'sms',
model: 'res.partner',
res_id: resPartnerId1,
});
pyEnv['mail.notification'].create({
mail_message_id: mailMessageId1,
notification_status: 'exception',
notification_type: 'sms',
res_partner_id: resPartnerId1,
});
const { env, openFormView } = await start();
await openFormView({
res_id: resPartnerId1,
res_model: 'res.partner',
});
patchWithCleanup(env.services.action, {
doAction(action, options) {
assert.step('do_action');
assert.strictEqual(
action,
'sms.sms_resend_action',
"action should be the one to resend sms"
);
assert.strictEqual(
options.additionalContext.default_mail_message_id,
mailMessageId1,
"action should have correct message id"
);
openResendActionDef.resolve();
},
});
assert.containsOnce(
document.body,
'.o_Message',
"should display a message component"
);
assert.containsOnce(
document.body,
'.o_Message_notificationIconClickable',
"should display the notification icon container"
);
assert.containsOnce(
document.body,
'.o_Message_notificationIcon',
"should display the notification icon"
);
assert.hasClass(
document.querySelector('.o_Message_notificationIcon'),
'fa-mobile',
"icon should represent sms"
);
document.querySelector('.o_Message_notificationIconClickable').click();
await openResendActionDef;
assert.verifySteps(
['do_action'],
"should do an action to display the resend sms dialog"
);
});
});
});

View file

@ -0,0 +1,261 @@
/** @odoo-module **/
import { start, startServer } from '@mail/../tests/helpers/test_utils';
import { patchWithCleanup } from '@web/../tests/helpers/utils';
QUnit.module('sms', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('notification_list', {}, function () {
QUnit.module('notification_list_notification_group_tests.js');
QUnit.test('mark as read', async function (assert) {
assert.expect(2);
const pyEnv = await startServer();
const mailChannelId1 = pyEnv['mail.channel'].create({});
const mailMessageId1 = pyEnv['mail.message'].create(
// message that is expected to have a failure
{
author_id: pyEnv.currentPartnerId,
message_type: 'sms',
model: 'mail.channel',
res_id: mailChannelId1,
}
);
pyEnv['mail.notification'].create(
// failure that is expected to be used in the test
{
mail_message_id: mailMessageId1, // id of the related message
notification_status: 'exception', // necessary value to have a failure
notification_type: 'sms',
}
);
const { afterNextRender, click } = await start();
await click('.o_MessagingMenu_toggler');
assert.containsOnce(
document.body,
'.o_NotificationGroup_markAsRead',
"should have 1 mark as read button"
);
await afterNextRender(() => {
document.querySelector('.o_NotificationGroup_markAsRead').click();
});
assert.containsNone(
document.body,
'.o_NotificationGroup',
"should have no notification group"
);
});
QUnit.test('notifications grouped by notification_type', async function (assert) {
assert.expect(11);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create({});
const [mailMessageId1, mailMessageId2] = pyEnv['mail.message'].create([
{
message_type: 'sms', // different type from second message
model: 'res.partner', // same model as second message (and not `mail.channel`)
res_id: resPartnerId1, // same res_id as second message
res_model_name: "Partner", // random related model name
},
{
message_type: 'email', // different type from first message
model: 'res.partner', // same model as first message (and not `mail.channel`)
res_id: resPartnerId1, // same res_id as first message
res_model_name: "Partner", // same related model name for consistency
},
]);
pyEnv['mail.notification'].create([
{
mail_message_id: mailMessageId1, // id of the related first message
notification_status: 'exception', // necessary value to have a failure
notification_type: 'sms', // different type from second failure
},
{
mail_message_id: mailMessageId1,
notification_status: 'exception',
notification_type: 'sms',
},
{
mail_message_id: mailMessageId2, // id of the related second message
notification_status: 'exception', // necessary value to have a failure
notification_type: 'email', // different type from first failure
},
{
mail_message_id: mailMessageId2,
notification_status: 'exception',
notification_type: 'email',
},
]);
const { click } = await start();
await click('.o_MessagingMenu_toggler');
assert.containsN(
document.body,
'.o_NotificationGroup',
2,
"should have 2 notifications group"
);
const groups = document.querySelectorAll('.o_NotificationGroup');
assert.containsOnce(
groups[0],
'.o_NotificationGroup_name',
"should have 1 group name in first group"
);
assert.strictEqual(
groups[0].querySelector('.o_NotificationGroup_name').textContent,
"Partner",
"should have model name as group name"
);
assert.containsOnce(
groups[0],
'.o_NotificationGroup_counter',
"should have 1 group counter in first group"
);
assert.strictEqual(
groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
"(2)",
"should have 2 notifications in first group"
);
assert.strictEqual(
groups[0].querySelector('.o_NotificationGroup_inlineText').textContent.trim(),
"An error occurred when sending an email.",
"should have the group text corresponding to email"
);
assert.containsOnce(
groups[1],
'.o_NotificationGroup_name',
"should have 1 group name in second group"
);
assert.strictEqual(
groups[1].querySelector('.o_NotificationGroup_name').textContent,
"Partner",
"should have second model name as group name"
);
assert.containsOnce(
groups[1],
'.o_NotificationGroup_counter',
"should have 1 group counter in second group"
);
assert.strictEqual(
groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
"(2)",
"should have 2 notifications in second group"
);
assert.strictEqual(
groups[1].querySelector('.o_NotificationGroup_inlineText').textContent.trim(),
"An error occurred when sending an SMS.",
"should have the group text corresponding to sms"
);
});
QUnit.test('grouped notifications by document model', async function (assert) {
// If all failures linked to a document model refers to different documents,
// a single notification should group all failures that are linked to this
// document model.
assert.expect(12);
const pyEnv = await startServer();
const [mailMessageId1, mailMessageId2] = pyEnv['mail.message'].create([
// first message that is expected to have a failure
{
message_type: 'sms', // message must be sms (goal of the test)
model: 'res.partner', // same model as second message (and not `mail.channel`)
res_id: 31, // different res_id from second message
res_model_name: "Partner", // random related model name
},
// second message that is expected to have a failure
{
message_type: 'sms', // message must be sms (goal of the test)
model: 'res.partner', // same model as first message (and not `mail.channel`)
res_id: 32, // different res_id from first message
res_model_name: "Partner", // same related model name for consistency
},
]);
pyEnv['mail.notification'].create([
// first failure that is expected to be used in the test
{
mail_message_id: mailMessageId1, // id of the related first message
notification_status: 'exception', // necessary value to have a failure
notification_type: 'sms', // expected failure type for sms message
},
// second failure that is expected to be used in the test
{
mail_message_id: mailMessageId2, // id of the related second message
notification_status: 'exception', // necessary value to have a failure
notification_type: 'sms', // expected failure type for sms message
},
]);
const { click, env } = await start();
patchWithCleanup(env.services.action, {
doAction(action) {
assert.step('do_action');
assert.strictEqual(
action.name,
"SMS Failures",
"action should have 'SMS Failures' as name",
);
assert.strictEqual(
action.type,
'ir.actions.act_window',
"action should have the type act_window"
);
assert.strictEqual(
action.view_mode,
'kanban,list,form',
"action should have 'kanban,list,form' as view_mode"
);
assert.strictEqual(
JSON.stringify(action.views),
JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]),
"action should have correct views"
);
assert.strictEqual(
action.target,
'current',
"action should have 'current' as target"
);
assert.strictEqual(
action.res_model,
'res.partner',
"action should have the group model as res_model"
);
assert.strictEqual(
JSON.stringify(action.domain),
JSON.stringify([['message_has_sms_error', '=', true]]),
"action should have 'message_has_sms_error' as domain"
);
},
});
await click('.o_MessagingMenu_toggler');
assert.containsOnce(
document.body,
'.o_NotificationGroup',
"should have 1 notification group"
);
assert.containsOnce(
document.body,
'.o_NotificationGroup_counter',
"should have 1 group counter"
);
assert.strictEqual(
document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
"(2)",
"should have 2 notifications in the group"
);
document.querySelector('.o_NotificationGroup').click();
assert.verifySteps(
['do_action'],
"should do an action to display the related records"
);
});
});
});
});

View file

@ -0,0 +1,196 @@
/** @odoo-module **/
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { click, editInput, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
let serverData;
let target;
QUnit.module(
"fields",
{
beforeEach: function () {
serverData = {
models: {
partner: {
fields: {
message: { string: "message", type: "text" },
foo: { string: "Foo", type: "char", default: "My little Foo Value" },
mobile: { string: "mobile", type: "text" },
},
records: [
{
id: 1,
message: "",
foo: "yop",
mobile: "+32494444444",
},
{
id: 2,
message: "",
foo: "bayou",
},
],
},
visitor: {
fields: {
mobile: { string: "mobile", type: "text" },
},
records: [
{
id: 1,
mobile: "+32494444444",
},
],
},
},
};
setupViewRegistries();
target = getFixture();
},
},
function () {
QUnit.module("SmsButton");
QUnit.test("Sms button in form view", async function (assert) {
await makeView({
type: "form",
resModel: "visitor",
resId: 1,
serverData,
arch: /* xml */ `
<form>
<sheet>
<field name="mobile" widget="phone"/>
</sheet>
</form>`,
});
assert.containsOnce(
target.querySelector(".o_field_phone"),
".o_field_phone_sms",
"the button is present"
);
});
QUnit.test("Sms button with option enable_sms set as False", async function (assert) {
await makeView({
type: "form",
resModel: "visitor",
resId: 1,
serverData,
mode: "readonly",
arch: /* xml */ `
<form>
<sheet>
<field name="mobile" widget="phone" options="{'enable_sms': false}"/>
</sheet>
</form>`,
});
assert.containsNone(
target.querySelector(".o_field_phone"),
".o_field_phone_sms",
"the button is not present"
);
});
QUnit.test(
"click on the sms button while creating a new record in a FormView",
async function (assert) {
const form = await makeView({
type: "form",
resModel: "partner",
serverData,
arch: /* xml */ `
<form>
<sheet>
<field name="foo"/>
<field name="mobile" widget="phone"/>
</sheet>
</form>`,
});
patchWithCleanup(form.env.services.action, {
doAction: (action, options) => {
assert.strictEqual(action.type, "ir.actions.act_window");
assert.strictEqual(action.res_model, "sms.composer");
options.onClose();
},
});
await editInput(target, "[name='foo'] input", "John");
await editInput(target, "[name='mobile'] input", "+32494444411");
await click(target, ".o_field_phone_sms", true);
assert.strictEqual(target.querySelector("[name='foo'] input").value, "John");
assert.strictEqual(
target.querySelector("[name='mobile'] input").value,
"+32494444411"
);
}
);
QUnit.test(
"click on the sms button in a FormViewDialog has no effect on the main form view",
async function (assert) {
serverData.models.partner.fields.partner_ids = {
string: "one2many partners field",
type: "one2many",
relation: "partner",
};
const form = await makeView({
type: "form",
resModel: "partner",
serverData,
arch: /* xml */ `
<form>
<sheet>
<field name="foo"/>
<field name="mobile" widget="phone"/>
<field name="partner_ids">
<kanban>
<field name="display_name"/>
<templates>
<t t-name="kanban-box">
<div><t t-esc="record.display_name"/></div>
</t>
</templates>
</kanban>
</field>
</sheet>
</form>`,
});
patchWithCleanup(form.env.services.action, {
doAction: (action, options) => {
assert.strictEqual(action.type, "ir.actions.act_window");
assert.strictEqual(action.res_model, "sms.composer");
options.onClose();
},
});
await editInput(target, "[name='foo'] input", "John");
await editInput(target, "[name='mobile'] input", "+32494444411");
await click(target, "[name='partner_ids'] .o-kanban-button-new");
assert.containsOnce(target, ".modal");
const modal = target.querySelector(".modal");
await editInput(modal, "[name='foo'] input", "Max");
await editInput(modal, "[name='mobile'] input", "+324955555");
await click(modal, ".o_field_phone_sms", true);
assert.strictEqual(modal.querySelector("[name='foo'] input").value, "Max");
assert.strictEqual(
modal.querySelector("[name='mobile'] input").value,
"+324955555"
);
await click(modal, ".o_form_button_cancel");
assert.strictEqual(target.querySelector("[name='foo'] input").value, "John");
assert.strictEqual(
target.querySelector("[name='mobile'] input").value,
"+32494444411"
);
}
);
}
);

View file

@ -0,0 +1,225 @@
/** @odoo-module **/
import FormView from 'web.FormView';
import ListView from 'web.ListView';
import testUtils from 'web.test_utils';
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { getFixture } from "@web/../tests/helpers/utils";
const createView = testUtils.createView;
QUnit.module('fields', {
beforeEach: function () {
this.data = {
partner: {
fields: {
message: {string: "message", type: "text"},
foo: {string: "Foo", type: "char", default: "My little Foo Value"},
mobile: {string: "mobile", type: "text"},
},
records: [{
id: 1,
message: "",
foo: 'yop',
mobile: "+32494444444",
}, {
id: 2,
message: "",
foo: 'bayou',
}]
},
visitor: {
fields: {
mobile: {string: "mobile", type: "text"},
},
records: [{
id: 1,
mobile: "+32494444444",
}]
},
};
setupViewRegistries();
this.target = getFixture();
}
}, function () {
QUnit.module('SmsWidget');
QUnit.test('Sms widgets are correctly rendered', async function (assert) {
assert.expect(9);
await makeView({
type: "form",
resModel: "partner",
serverData: { models: this.data },
arch: /* xml */ `<form><sheet><field name="message" widget="sms_widget"/></sheet></form>`,
});
assert.containsOnce(this.target, '.o_sms_count', "Should have a sms counter");
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '0 characters, fits in 0 SMS (GSM7) ',
'Should be "0 characters, fits in 0 SMS (GSM7) " by default');
// GSM-7
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), "Hello from Odoo", 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '15 characters, fits in 1 SMS (GSM7) ',
'Should be "15 characters, fits in 1 SMS (GSM7) " for "Hello from Odoo"');
// GSM-7 with \n => this one count as 2 characters
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), "Hello from Odoo\n", 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '17 characters, fits in 1 SMS (GSM7) ',
'Should be "17 characters, fits in 1 SMS (GSM7) " for "Hello from Odoo\\n"');
// Unicode => ê
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), "Hêllo from Odoo", 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '15 characters, fits in 1 SMS (UNICODE) ',
'Should be "15 characters, fits in 1 SMS (UNICODE) " for "Hêllo from Odoo"');
// GSM-7 with 160c
var text = Array(161).join('a');
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), text, 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '160 characters, fits in 1 SMS (GSM7) ',
'Should be "160 characters, fits in 1 SMS (GSM7) " for 160 x "a"');
// GSM-7 with 161c
text = Array(162).join('a');
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), text, 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '161 characters, fits in 2 SMS (GSM7) ',
'Should be "161 characters, fits in 2 SMS (GSM7) " for 161 x "a"');
// Unicode with 70c
text = Array(71).join('ê');
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), text, 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '70 characters, fits in 1 SMS (UNICODE) ',
'Should be "70 characters, fits in 1 SMS (UNICODE) " for 70 x "ê"');
// Unicode with 71c
text = Array(72).join('ê');
await testUtils.fields.editAndTrigger(this.target.querySelector('.o_input'), text, 'input');
assert.strictEqual(this.target.querySelector('.o_sms_count').textContent, '71 characters, fits in 2 SMS (UNICODE) ',
'Should be "71 characters, fits in 2 SMS (UNICODE) " for 71 x "ê"');
});
QUnit.test('Sms widgets with non-empty initial value', async function (assert) {
assert.expect(1);
await makeView({
type: "form",
resModel: "visitor",
resId: 1,
serverData: { models: this.data },
arch: /* xml */ `<form><sheet><field name="mobile" widget="sms_widget" readonly="true"/></sheet></form>`,
});
assert.strictEqual(this.target.querySelector('.o_field_text span').textContent, '+32494444444',
'Should have the initial value');
});
QUnit.test('Sms widgets with empty initial value', async function (assert) {
assert.expect(1);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData: { models: this.data },
arch: /* xml */ `<form><sheet><field name="message" widget="sms_widget" readonly="true"/></sheet></form>`,
});
assert.strictEqual(this.target.querySelector('.o_field_text span').textContent, '',
'Should have the empty initial value');
});
QUnit.module('PhoneWidget');
QUnit.test('phone field in editable list view on normal screens', async function (assert) {
assert.expect(11);
var doActionCount = 0;
var list = await createView({
View: ListView,
model: 'partner',
data: this.data,
debug:true,
arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>',
intercepts: {
do_action(ev) {
assert.equal(ev.data.action.res_model, 'sms.composer',
'The action to send an SMS should have been executed');
doActionCount += 1;
}
}
});
assert.containsN(list, 'tbody td:not(.o_list_record_selector)', 4);
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'yopSMS',
"value should be displayed properly with a link to send SMS");
assert.containsN(list, 'div.o_field_widget.o_form_uri.o_field_phone > a', 2,
"should have the correct classnames");
// Edit a line and check the result
var $cell = list.$('tbody td:not(.o_list_record_selector)').first();
await testUtils.dom.click($cell);
assert.hasClass($cell.parent(),'o_selected_row', 'should be set as edit mode');
assert.strictEqual($cell.find('input').val(), 'yop',
'should have the corect value in internal input');
await testUtils.fields.editInput($cell.find('input'), 'new');
// save
await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
$cell = list.$('tbody td:not(.o_list_record_selector)').first();
assert.doesNotHaveClass($cell.parent(), 'o_selected_row', 'should not be in edit mode anymore');
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'newSMS',
"value should be properly updated");
assert.containsN(list, 'div.o_field_widget.o_form_uri.o_field_phone > a', 2,
"should still have links with correct classes");
await testUtils.dom.click(list.$('tbody td:not(.o_list_record_selector) .o_field_phone_sms').first());
assert.equal(doActionCount, 1, 'Only one action should have been executed');
assert.containsNone(list, '.o_selected_row',
'None of the list element should have been activated');
list.destroy();
});
QUnit.test('readonly sms phone field is properly rerendered after been changed by onchange', async function (assert) {
assert.expect(4);
const NEW_PHONE = '+32595555555';
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="foo" on_change="1"/>' + // onchange to update mobile in readonly mode directly
'<field name="mobile" widget="phone" readonly="1"/>' + // readonly only, we don't want to go through write mode
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
viewOptions: {mode: 'edit'},
mockRPC: function (route, args) {
if (args.method === 'onchange') {
return Promise.resolve({
value: {
mobile: NEW_PHONE, // onchange to update mobile in readonly mode directly
},
});
}
return this._super.apply(this, arguments);
},
});
// check initial rendering
assert.strictEqual(form.$('.o_field_phone').text(), "+32494444444",
'Initial Phone text should be set');
assert.strictEqual(form.$('.o_field_phone_sms').text(), 'SMS',
'SMS button label should be rendered');
// trigger the onchange to update phone field, but still in readonly mode
await testUtils.fields.editInput($('input[name="foo"]'), 'someOtherFoo');
// check rendering after changes
assert.strictEqual(form.$('.o_field_phone').text(), NEW_PHONE,
'Phone text should be updated');
assert.strictEqual(form.$('.o_field_phone_sms').text(), 'SMS',
'SMS button label should not be changed');
form.destroy();
});
});

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

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