19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:39 +01:00
parent 5df8c07b59
commit daa394e8b0
2114 changed files with 564841 additions and 299642 deletions

View file

@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import iap_account
from . import ir_actions_server
from . import ir_model
from . import mail_followers
from . import mail_message
from . import mail_notification
from . import mail_thread
from . import mail_thread_phone
from . import models
from . import res_partner
from . import sms_api
from . import res_company
from . import sms_sms
from . import sms_template
from . import sms_tracker

View file

@ -0,0 +1,35 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
class IapAccount(models.Model):
_inherit = 'iap.account'
sender_name = fields.Char(help="This is the name that will be displayed as the sender of the SMS.", readonly=True)
def action_open_registration_wizard(self):
return {
'type': 'ir.actions.act_window',
'target': 'new',
'name': _('Register Account'),
'view_mode': 'form',
'res_model': 'sms.account.phone',
'context': {'default_account_id': self.id},
}
def action_open_sender_name_wizard(self):
return {
'type': 'ir.actions.act_window',
'target': 'new',
'name': _('Choose your sender name'),
'view_mode': 'form',
'res_model': 'sms.account.sender',
'context': {'default_account_id': self.id},
}
def _get_account_info(self, account_id, balance, information):
res = super()._get_account_info(account_id, balance, information)
if account_id.service_name == 'sms':
res['sender_name'] = information.get('sender_name')
return res

View file

@ -2,16 +2,14 @@
# 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):
class IrActionsServer(models.Model):
""" Add SMS option in server actions. """
_name = 'ir.actions.server'
_inherit = ['ir.actions.server']
_inherit = 'ir.actions.server'
state = fields.Selection(selection_add=[
('sms', 'Send SMS Text Message'),
('sms', 'Send SMS'), ('followers',),
], ondelete={'sms': 'cascade'})
# SMS
sms_template_id = fields.Many2one(
@ -21,11 +19,28 @@ class ServerActions(models.Model):
domain="[('model_id', '=', model_id)]",
)
sms_method = fields.Selection(
selection=[('sms', 'SMS'), ('comment', 'Post as Message'), ('note', 'Post as Note')],
string='Send as (SMS)',
selection=[('sms', 'SMS (without note)'), ('comment', 'SMS (with note)'), ('note', 'Note only')],
string='Send SMS As',
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')
readonly=False, store=True)
def _name_depends(self):
return [*super()._name_depends(), "sms_template_id"]
def _generate_action_name(self):
self.ensure_one()
if self.state == 'sms' and self.sms_template_id:
return _('Send %(template_name)s', template_name=self.sms_template_id.name)
return super()._generate_action_name()
@api.depends('state')
def _compute_available_model_ids(self):
mail_thread_based = self.filtered(lambda action: action.state == 'sms')
if mail_thread_based:
mail_models = self.env['ir.model'].search([('is_mail_thread', '=', True), ('transient', '=', False)])
for action in mail_thread_based:
action.available_model_ids = mail_models.ids
super(IrActionsServer, self - mail_thread_based)._compute_available_model_ids()
@api.depends('model_id', 'state')
def _compute_sms_template_id(self):
@ -45,11 +60,30 @@ class ServerActions(models.Model):
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"))
@api.model
def _warning_depends(self):
return super()._warning_depends() + [
'model_id',
'state',
'sms_template_id',
]
def _get_warning_messages(self):
self.ensure_one()
warnings = super()._get_warning_messages()
if self.state == 'sms':
if self.model_id.transient or not self.model_id.is_mail_thread:
warnings.append(_("Sending SMS can only be done on a not transient mail.thread model"))
if self.sms_template_id and self.sms_template_id.model_id != self.model_id:
warnings.append(
_('SMS template model of %(action_name)s does not match action model.',
action_name=self.name
)
)
return warnings
def _run_action_sms_multi(self, eval_context=None):
# TDE CLEANME: when going to new api with server action, remove action

View file

@ -18,24 +18,23 @@ class IrModel(models.Model):
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()
potential_fields = ModelObject._phone_get_number_fields() + ModelObject._mail_get_partner_fields()
if any(fname in ModelObject._fields for fname in potential_fields):
model.is_mail_thread_sms = True
continue
model.is_mail_thread_sms = False
def _search_is_mail_thread_sms(self, operator, value):
if operator != 'in':
return NotImplemented
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()
potential_fields = ModelObject._phone_get_number_fields() + ModelObject._mail_get_partner_fields()
if any(fname in ModelObject._fields for fname in potential_fields):
valid_models |= model
search_sms = (operator == '=' and value) or (operator == '!=' and not value)
if search_sms:
return [('id', 'in', valid_models.ids)]
return [('id', 'not in', valid_models.ids)]
return [('id', 'in', valid_models.ids)]

View file

@ -4,23 +4,23 @@
from odoo import models
class Followers(models.Model):
_inherit = ['mail.followers']
class MailFollowers(models.Model):
_inherit = 'mail.followers'
def _get_recipient_data(self, records, message_type, subtype_id, pids=None):
recipients_data = super()._get_recipient_data(records, message_type, subtype_id, pids=pids)
if message_type != 'sms' or not (pids or records):
return super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids)
return recipients_data
if pids is None and records:
records_pids = dict(
(record.id, record._sms_get_default_partners().ids)
for record in records
(rec_id, partners.ids)
for rec_id, partners in records._mail_get_partners().items()
)
elif pids and records:
records_pids = dict((record.id, pids) for record in records)
else:
records_pids = {0: pids if pids else []}
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():

View file

@ -1,11 +1,6 @@
# -*- 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
from odoo import fields, models
class MailMessage(models.Model):
@ -14,9 +9,9 @@ class MailMessage(models.Model):
gateway. """
_inherit = 'mail.message'
message_type = fields.Selection(selection_add=[
('sms', 'SMS')
], ondelete={'sms': lambda recs: recs.write({'message_type': 'email'})})
message_type = fields.Selection(
selection_add=[('sms', 'SMS')],
ondelete={'sms': lambda recs: recs.write({'message_type': 'comment'})})
has_sms_error = fields.Boolean(
'Has SMS error', compute='_compute_has_sms_error', search='_search_has_sms_error')
@ -29,26 +24,9 @@ class MailMessage(models.Model):
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
if operator != 'in':
return NotImplemented
return [('notification_ids', 'any', [
('notification_status', '=', 'exception'),
('notification_type', '=', 'sms'),
])]

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import api, fields, models
class MailNotification(models.Model):
@ -10,12 +10,35 @@ class MailNotification(models.Model):
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_id_int = fields.Integer('SMS ID', index='btree_not_null')
# Used to give links on form view without foreign key. In most cases, you'd want to use sms_id_int or sms_tracker_ids.sms_uuid.
sms_id = fields.Many2one('sms.sms', string='SMS', store=False, compute='_compute_sms_id')
sms_tracker_ids = fields.One2many('sms.tracker', 'mail_notification_id', string="SMS Trackers")
sms_number = fields.Char('SMS Number', groups='base.group_user')
failure_type = fields.Selection(selection_add=[
('sms_number_missing', 'Missing Number'),
('sms_number_format', 'Wrong Number Format'),
('sms_credit', 'Insufficient Credit'),
('sms_country_not_supported', 'Country Not Supported'),
('sms_registration_needed', 'Country-specific Registration Required'),
('sms_server', 'Server Error'),
('sms_acc', 'Unregistered Account')
('sms_acc', 'Unregistered Account'),
# delivery report errors
('sms_expired', 'Expired'),
('sms_invalid_destination', 'Invalid Destination'),
('sms_not_allowed', 'Not Allowed'),
('sms_not_delivered', 'Not Delivered'),
('sms_rejected', 'Rejected'),
])
@api.depends('sms_id_int', 'notification_type')
def _compute_sms_id(self):
self.sms_id = False
sms_notifications = self.filtered(lambda n: n.notification_type == 'sms' and bool(n.sms_id_int))
if not sms_notifications:
return
existing_sms_ids = self.env['sms.sms'].sudo().search([
('id', 'in', sms_notifications.mapped('sms_id_int')), ('to_delete', '!=', True)
]).ids
for sms_notification in sms_notifications.filtered(lambda n: n.sms_id_int in set(existing_sms_ids)):
sms_notification.sms_id = sms_notification.sms_id_int

View file

@ -3,8 +3,7 @@
import logging
from odoo import api, models, fields
from odoo.addons.phone_validation.tools import phone_validation
from odoo import api, Command, models, fields
from odoo.addons.sms.tools.sms_tools import sms_content_to_rendered_html
from odoo.tools import html2plaintext
@ -34,16 +33,17 @@ class MailThread(models.AbstractModel):
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())
res.update(self.env.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)]
if operator != 'in':
return NotImplemented
return ['&', ('message_ids.has_sms_error', '=', True), ('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).
@ -136,30 +136,25 @@ class MailThread(models.AbstractModel):
)
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)
# Main notification method. Override to add support of sending SMS notifications.
scheduled_date = self._is_notification_scheduled(kwargs.get('scheduled_date'))
recipients_data = super()._notify_thread(message, msg_vals=msg_vals, **kwargs)
if not scheduled_date:
self._notify_thread_by_sms(message, recipients_data, msg_vals=msg_vals, **kwargs)
return recipients_data
def _notify_thread_by_sms(self, message, recipients_data, msg_vals=False,
sms_content=None, sms_numbers=None, sms_pid_to_number=None,
resend_existing=False, put_in_queue=False, **kwargs):
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 record message: <mail.message> record being notified. May be
void as 'msg_vals' superseeds it;
:param list recipients_data: list of recipients data based on <res.partner>
records formatted like a list of dicts containing information. See
``MailThread._notify_get_recipients()``;
:param dict msg_vals: values dict used to create the message, allows to
skip message usage and spare some queries if given;
:param sms_content: plaintext version of body, mainly to avoid
conversion glitches by splitting html and plain text content formatting
@ -167,20 +162,19 @@ class MailThread(models.AbstractModel):
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
:param sms_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;
"""
msg_vals = msg_vals or {}
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)
body = sms_content or html2plaintext(msg_vals['body'] if 'body' in msg_vals else message.body)
sms_base_vals = {
'body': body,
'mail_message_id': message.id,
@ -192,21 +186,18 @@ class MailThread(models.AbstractModel):
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
number = sms_pid_to_number.get(partner.id) or partner.phone
sms_create_vals.append(dict(
sms_base_vals,
partner_id=partner.id,
number=number
number=partner._phone_format(number=number) or 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()
self._phone_format(number=sms_number) or sms_number
for sms_number in sms_numbers
]
existing_partners_numbers = {vals_dict['number'] for vals_dict in sms_create_vals}
sms_create_vals += [dict(
@ -218,55 +209,34 @@ class MailThread(models.AbstractModel):
) 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,
'sms_id_int': sms.id,
'sms_tracker_ids': [Command.create({'sms_uuid': sms.uuid})] if sms.state == 'outgoing' else False,
'is_read': True, # discard Inbox notification
'notification_status': 'ready' if sms.state == 'outgoing' else 'exception',
'failure_type': '' if sms.state == 'outgoing' else sms.failure_type,
} for sms in sms_all if (sms.partner_id and sms.partner_id.id not in existing_pids) or (not sms.partner_id and sms.number not in existing_numbers)]
} for sms in sms_all]
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)
sms_all.filtered(lambda sms: sms.state == 'outgoing').send(raise_exception=False)
return True
def _get_notify_valid_parameters(self):
return super()._get_notify_valid_parameters() | {
'put_in_queue', 'sms_numbers', 'sms_pid_to_number', 'sms_content',
}
@api.model
def notify_cancel_by_type(self, notification_type):
super().notify_cancel_by_type(notification_type)

View file

@ -1,16 +0,0 @@
# -*- 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

@ -5,36 +5,8 @@ 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
""" Get SMS recipient information on current record set. This method
checks for numbers and sanitation in order to centralize computation.
Example of use cases
@ -45,32 +17,52 @@ class BaseModel(models.AbstractModel):
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``;
generic heuristic is used to find one based on :meth:`_phone_get_number_fields`;
:param partner_fallback: if no value found in the record, check its customer
values based on ``_sms_get_default_partners``;
values based on :meth:`_mail_get_partners`;
:rtype: dict[int, dict[str, Any]]
:return: a dictionnary with the following structure:
.. code-block:: python
{
record.id: {
# a res.partner recordset that is the customer (void or
# singleton) linked to the recipient.
# See _mail_get_partners;
'partner': ...,
# sanitized number to use (coming from record's field
# or partner's phone fields). Set to False if number
# impossible to parse and format;
'sanitized': ...,
# original number before sanitation;
'number': ...,
# 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;
'partner_store': ...,
# field in which the number has been found (generally
# mobile or phone, see _phone_get_number_fields);
'field_store': ...,
}
for record in self
}
: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()
tocheck_fields = [force_field] if force_field else self._phone_get_number_fields()
for record in self:
all_numbers = [record[fname] for fname in tocheck_fields if fname in record]
all_partners = record._sms_get_default_partners()
all_partners = record._mail_get_partners()[record.id]
valid_number = False
valid_number, fname = False, 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']
valid_number = record._phone_format(fname=fname)
if valid_number:
break
@ -85,13 +77,13 @@ class BaseModel(models.AbstractModel):
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']
for fname in self.env['res.partner']._phone_get_number_fields():
valid_number = partner._phone_format(fname=fname)
if valid_number:
break
if not valid_number:
fname = 'mobile' if partner.mobile else ('phone' if partner.phone else 'mobile')
fname = 'phone'
result[record.id] = {
'partner': partner,

View file

@ -0,0 +1,11 @@
from odoo import models
from odoo.addons.sms.tools.sms_api import SmsApi
class ResCompany(models.Model):
_inherit = 'res.company'
def _get_sms_api_class(self):
self.ensure_one()
return SmsApi

View file

@ -1,20 +0,0 @@
# -*- 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

@ -1,79 +0,0 @@
# -*- 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

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import threading
from uuid import uuid4
from odoo import api, fields, models, tools, _
from odoo.addons.sms.tools.sms_api import SmsApi
from odoo.tools.urls import urljoin as url_join
_logger = logging.getLogger(__name__)
@ -15,93 +17,111 @@ class SmsSms(models.Model):
_rec_name = 'number'
_order = 'id DESC'
IAP_TO_SMS_STATE = {
'success': 'sent',
IAP_TO_SMS_STATE_SUCCESS = {
'processing': 'process',
'success': 'pending',
# These below are not returned in responses from IAP API in _send but are received via webhook events.
'sent': 'pending',
'delivered': 'sent',
}
IAP_TO_SMS_FAILURE_TYPE = { # TODO RIGR remove me in master
'insufficient_credit': 'sms_credit',
'wrong_number_format': 'sms_number_format',
'country_not_supported': 'sms_country_not_supported',
'server_error': 'sms_server',
'unregistered': 'sms_acc'
}
BOUNCE_DELIVERY_ERRORS = {'sms_invalid_destination', 'sms_not_allowed', 'sms_rejected'}
DELIVERY_ERRORS = {'sms_expired', 'sms_not_delivered', *BOUNCE_DELIVERY_ERRORS}
uuid = fields.Char('UUID', copy=False, readonly=True, default=lambda self: uuid4().hex,
help='Alternate way to identify a SMS record, used for delivery reports')
number = fields.Char('Number')
body = fields.Text()
partner_id = fields.Many2one('res.partner', 'Customer')
mail_message_id = fields.Many2one('mail.message', index=True)
state = fields.Selection([
('outgoing', 'In Queue'),
('sent', 'Sent'),
('process', 'Processing'),
('pending', 'Sent'),
('sent', 'Delivered'), # As for notifications and traces
('error', 'Error'),
('canceled', 'Canceled')
('canceled', 'Cancelled')
], 'SMS Status', readonly=True, copy=False, default='outgoing', required=True)
failure_type = fields.Selection([
("unknown", "Unknown error"),
('sms_number_missing', 'Missing Number'),
('sms_number_format', 'Wrong Number Format'),
('sms_country_not_supported', 'Country Not Supported'),
('sms_registration_needed', 'Country-specific Registration Required'),
('sms_credit', 'Insufficient Credit'),
('sms_server', 'Server Error'),
('sms_acc', 'Unregistered Account'),
# mass mode specific codes
# mass mode specific codes, generated internally, not returned by IAP.
('sms_blacklist', 'Blacklisted'),
('sms_duplicate', 'Duplicate'),
('sms_optout', 'Opted Out'),
], copy=False)
sms_tracker_id = fields.Many2one('sms.tracker', string='SMS trackers', compute='_compute_sms_tracker_id')
to_delete = fields.Boolean(
'Marked for deletion', default=False,
help='Will automatically be deleted, while notifications will not be deleted in any case.'
)
_uuid_unique = models.Constraint(
'unique(uuid)',
'UUID must be unique',
)
@api.model_create_multi
def create(self, vals_list):
self.env.ref('sms.ir_cron_sms_scheduler_action')._trigger()
return super().create(vals_list)
@api.depends('uuid')
def _compute_sms_tracker_id(self):
self.sms_tracker_id = False
existing_trackers = self.env['sms.tracker'].search([('sms_uuid', 'in', self.filtered('uuid').mapped('uuid'))])
tracker_ids_by_sms_uuid = {tracker.sms_uuid: tracker.id for tracker in existing_trackers}
for sms in self.filtered(lambda s: s.uuid in tracker_ids_by_sms_uuid):
sms.sms_tracker_id = tracker_ids_by_sms_uuid[sms.uuid]
def action_set_canceled(self):
self.state = 'canceled'
notifications = self.env['mail.notification'].sudo().search([
('sms_id', 'in', self.ids),
# sent is sent -> cannot reset
('notification_status', 'not in', ['canceled', 'sent']),
])
if notifications:
notifications.write({'notification_status': 'canceled'})
if not self._context.get('sms_skip_msg_notification', False):
notifications.mail_message_id._notify_message_notification_update()
self._update_sms_state_and_trackers('canceled')
def action_set_error(self, failure_type):
self.state = 'error'
self.failure_type = failure_type
notifications = self.env['mail.notification'].sudo().search([
('sms_id', 'in', self.ids),
# sent can be set to error due to IAP feedback
('notification_status', '!=', 'exception'),
])
if notifications:
notifications.write({'notification_status': 'exception', 'failure_type': failure_type})
if not self._context.get('sms_skip_msg_notification', False):
notifications.mail_message_id._notify_message_notification_update()
self._update_sms_state_and_trackers('error', failure_type=failure_type)
def action_set_outgoing(self):
self.write({
'state': 'outgoing',
'failure_type': False
})
notifications = self.env['mail.notification'].sudo().search([
('sms_id', 'in', self.ids),
# sent is sent -> cannot reset
('notification_status', 'not in', ['ready', 'sent']),
])
if notifications:
notifications.write({'notification_status': 'ready', 'failure_type': False})
if not self._context.get('sms_skip_msg_notification', False):
notifications.mail_message_id._notify_message_notification_update()
self._update_sms_state_and_trackers('outgoing', failure_type=False)
def send(self, unlink_failed=False, unlink_sent=True, auto_commit=False, raise_exception=False):
def send(self, unlink_failed=False, unlink_sent=True, raise_exception=False):
""" Main API method to send SMS.
This contacts an external server. If the transaction fails, it may be
retried which can result in sending multiple SMS messages!
: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()
domain = [('state', '=', 'outgoing'), ('to_delete', '!=', True)]
to_send = self.try_lock_for_update().filtered_domain(domain)
for sms_api, sms in to_send._split_by_api():
for batch_ids in sms._split_batch():
self.browse(batch_ids).with_context(sms_api=sms_api)._send(
unlink_failed=unlink_failed,
unlink_sent=unlink_sent,
raise_exception=raise_exception,
)
def _split_by_api(self):
yield SmsApi(self.env), self
def resend_failed(self):
sms_to_send = self.filtered(lambda sms: sms.state == 'error')
sms_to_send = self.filtered(lambda sms: sms.state == 'error' and not sms.to_delete)
sms_to_send.state = 'outgoing'
notification_title = _('Warning')
notification_type = 'danger'
@ -112,7 +132,7 @@ class SmsSms(models.Model):
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))
notification_message = _('%(count)s out of the %(total)s selected SMS Text Messages have successfully been resent.', count=success_sms, total=len(self))
else:
notification_message = _('The SMS Text Messages could not be resent.')
else:
@ -128,90 +148,93 @@ class SmsSms(models.Model):
}
@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!
def _process_queue(self):
""" CRON job to send queued SMS messages. """
domain = [('state', '=', 'outgoing'), ('to_delete', '!=', True)]
: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')]
batch_size = self._get_send_batch_size()
records = self.search(domain, limit=batch_size, order='id').try_lock_for_update()
if not records:
return
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()
records._send(unlink_failed=False, unlink_sent=True, raise_exception=False)
self.env['ir.cron']._commit_progress(len(records), remaining=self.search_count(domain) if len(records) == batch_size else 0)
res = None
try:
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
res = self.browse(ids).send(unlink_failed=False, unlink_sent=True, auto_commit=auto_commit, raise_exception=False)
except Exception:
_logger.exception("Failed processing SMS queue")
return res
def _get_send_batch_size(self):
return int(self.env['ir.config_parameter'].sudo().get_param('sms.session.batch.size', 500))
def _get_sms_company(self):
return self.mail_message_id.record_company_id or self.env.company
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
batch_size = self._get_send_batch_size()
yield from tools.split_every(batch_size, self.ids)
def _send(self, unlink_failed=False, unlink_sent=True, raise_exception=False):
""" This method tries to send SMS after checking the number (presence and
formatting). """
iap_data = [{
'res_id': record.id,
'number': record.number,
'content': record.body,
} for record in self]
"""Send SMS after checking the number (presence and formatting)."""
sms_api = self.env.context.get('sms_api')
if not sms_api:
company = self._get_sms_company()
company.ensure_one() # This should always be the case since the grouping is done in `send`
sms_api = company._get_sms_api_class()(self.env)
return self._send_with_api(
sms_api,
unlink_failed=unlink_failed,
unlink_sent=unlink_sent,
raise_exception=raise_exception,
)
def _send_with_api(self, sms_api, unlink_failed=False, unlink_sent=True, raise_exception=False):
"""Send SMS after checking the number (presence and formatting)."""
messages = [{
'content': body,
'numbers': [{'number': sms.number, 'uuid': sms.uuid} for sms in body_sms_records],
} for body, body_sms_records in self.grouped('body').items()]
delivery_reports_url = url_join(self[0].get_base_url(), '/sms/status')
try:
iap_results = self.env['sms.api']._send_sms_batch(iap_data)
results = sms_api._send_sms_batch(messages, delivery_reports_url=delivery_reports_url)
except Exception as e:
_logger.info('Sent batch %s SMS: %s: failed with exception %s', len(self.ids), self.ids, e)
if raise_exception:
raise
self._postprocess_iap_sent_sms(
[{'res_id': sms.id, 'state': 'server_error'} for sms in self],
unlink_failed=unlink_failed, unlink_sent=unlink_sent)
results = [{'uuid': sms.uuid, 'state': 'server_error'} for sms in self]
else:
_logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, iap_results)
self._postprocess_iap_sent_sms(iap_results, unlink_failed=unlink_failed, unlink_sent=unlink_sent)
_logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, results)
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, unlink_failed=False, unlink_sent=True):
todelete_sms_ids = []
if unlink_failed:
todelete_sms_ids += [item['res_id'] for item in iap_results if item['state'] != 'success']
if unlink_sent:
todelete_sms_ids += [item['res_id'] for item in iap_results if item['state'] == 'success']
results_uuids = [result['uuid'] for result in results]
all_sms_sudo = self.env['sms.sms'].sudo().search([('uuid', 'in', results_uuids)]).with_context(sms_skip_msg_notification=True)
for state in self.IAP_TO_SMS_STATE.keys():
sms_ids = [item['res_id'] for item in iap_results if item['state'] == state]
if sms_ids:
if state != 'success' and not unlink_failed:
self.env['sms.sms'].sudo().browse(sms_ids).write({
'state': 'error',
'failure_type': self.IAP_TO_SMS_STATE[state],
})
if state == 'success' and not unlink_sent:
self.env['sms.sms'].sudo().browse(sms_ids).write({
'state': 'sent',
'failure_type': False,
})
notifications = self.env['mail.notification'].sudo().search([
('notification_type', '=', 'sms'),
('sms_id', 'in', sms_ids),
('notification_status', 'not in', ('sent', 'canceled')),
])
if notifications:
notifications.write({
'notification_status': 'sent' if state == 'success' else 'exception',
'failure_type': self.IAP_TO_SMS_STATE[state] if state != 'success' else False,
'failure_reason': failure_reason if failure_reason else False,
})
self.mail_message_id._notify_message_notification_update()
for (iap_state, failure_reason), results_group in tools.groupby(results, key=lambda result: (result['state'], result.get('failure_reason'))):
sms_sudo = all_sms_sudo.filtered(lambda s: s.uuid in {result['uuid'] for result in results_group})
if success_state := self.IAP_TO_SMS_STATE_SUCCESS.get(iap_state):
sms_sudo.sms_tracker_id._action_update_from_sms_state(success_state)
to_delete = {'to_delete': True} if unlink_sent else {}
sms_sudo.write({'state': success_state, 'failure_type': False, **to_delete})
else:
failure_type = sms_api.PROVIDER_TO_SMS_FAILURE_TYPE.get(iap_state, 'unknown')
if failure_type != 'unknown':
sms_sudo.sms_tracker_id._action_update_from_sms_state('error', failure_type=failure_type, failure_reason=failure_reason)
else:
sms_sudo.sms_tracker_id.with_context(sms_known_failure_reason=failure_reason)._action_update_from_provider_error(iap_state)
to_delete = {'to_delete': True} if unlink_failed else {}
sms_sudo.write({'state': 'error', 'failure_type': failure_type, **to_delete})
if todelete_sms_ids:
self.browse(todelete_sms_ids).sudo().unlink()
all_sms_sudo._handle_call_result_hook(results)
all_sms_sudo.mail_message_id._notify_message_notification_update()
def _update_sms_state_and_trackers(self, new_state, failure_type=None):
"""Update sms state update and related tracking records (notifications, traces)."""
self.write({'state': new_state, 'failure_type': failure_type})
# Use sudo on mail.notification to allow writing other users' notifications; rights are already checked by sms write
self.sms_tracker_id.sudo()._action_update_from_sms_state(new_state, failure_type=failure_type)
def _handle_call_result_hook(self, results):
"""Further process SMS sending API results."""
pass
@api.autovacuum
def _gc_device(self):
self.env.cr.execute("DELETE FROM sms_sms WHERE to_delete = TRUE")
_logger.info("GC'd %d sms marked for deletion", self.env.cr.rowcount)

View file

@ -4,9 +4,9 @@
from odoo import api, fields, models, _
class SMSTemplate(models.Model):
class SmsTemplate(models.Model):
"Templates for sending SMS"
_name = "sms.template"
_name = 'sms.template'
_inherit = ['mail.render.mixin', 'template.reset.mixin']
_description = 'SMS Templates'
@ -14,8 +14,8 @@ class SMSTemplate(models.Model):
@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 = super().default_get(fields)
if 'model_id' in fields and not res.get('model_id') and res.get('model'):
res['model_id'] = self.env['ir.model']._get(res['model']).id
return res
@ -41,15 +41,13 @@ class SMSTemplate(models.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 copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", template.name)) for template, vals in zip(self, vals_list)]
def unlink(self):
self.sudo().mapped('sidebar_action_id').unlink()
return super(SMSTemplate, self).unlink()
return super().unlink()
def action_create_sidebar_action(self):
ActWindow = self.env['ir.actions.act_window']

View file

@ -0,0 +1,83 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class SmsTracker(models.Model):
"""Relationship between a sent SMS and tracking records such as notifications and traces.
This model acts as an extension of a `mail.notification` or a `mailing.trace` and allows to
update those based on the SMS provider responses both at sending and when later receiving
sent/delivery reports (see `SmsController`).
SMS trackers are supposed to be created manually when necessary, and tied to their related
SMS through the SMS UUID field. (They are not tied to the SMS records directly as those can
be deleted when sent).
Note: Only admins/system user should need to access (a fortiori modify) these technical
records so no "sudo" is used nor should be required here.
"""
_name = 'sms.tracker'
_description = "Link SMS to mailing/sms tracking models"
SMS_STATE_TO_NOTIFICATION_STATUS = {
'canceled': 'canceled',
'process': 'process',
'error': 'exception',
'outgoing': 'ready',
'sent': 'sent',
'pending': 'pending',
}
sms_uuid = fields.Char('SMS uuid', required=True)
mail_notification_id = fields.Many2one('mail.notification', ondelete='cascade', index='btree_not_null')
_sms_uuid_unique = models.Constraint(
'unique(sms_uuid)',
'A record for this UUID already exists',
)
def _action_update_from_provider_error(self, provider_error):
"""
:param str provider_error: value returned by SMS service provider (IAP) or any string.
If provided, notification values will be derived from it.
(see ``_get_tracker_values_from_provider_error``)
"""
failure_reason = self.env.context.get("sms_known_failure_reason") # TODO RIGR in master: pass as param instead of context
failure_type = f'sms_{provider_error}'
error_status = None
if failure_type not in self.env['sms.sms'].DELIVERY_ERRORS:
failure_type = 'unknown'
failure_reason = failure_reason or provider_error
elif failure_type in self.env['sms.sms'].BOUNCE_DELIVERY_ERRORS:
error_status = "bounce"
self._update_sms_notifications(error_status or 'exception', failure_type=failure_type, failure_reason=failure_reason)
return error_status, failure_type, failure_reason
def _action_update_from_sms_state(self, sms_state, failure_type=False, failure_reason=False):
notification_status = self.SMS_STATE_TO_NOTIFICATION_STATUS[sms_state]
self._update_sms_notifications(notification_status, failure_type=failure_type, failure_reason=failure_reason)
def _update_sms_notifications(self, notification_status, failure_type=False, failure_reason=False):
# canceled is a state which means that the SMS sending order should not be sent to the SMS service.
# `process`, `pending` are sent to IAP which is not revertible (as `sent` which means "delivered").
notifications_statuses_to_ignore = {
'canceled': ['canceled', 'process', 'pending', 'sent'],
'ready': ['ready', 'process', 'pending', 'sent'],
'process': ['process', 'pending', 'sent'],
'pending': ['pending', 'sent'],
'bounce': ['bounce', 'sent'],
'sent': ['sent'],
'exception': ['exception'],
}[notification_status]
notifications = self.mail_notification_id.filtered(
lambda n: n.notification_status not in notifications_statuses_to_ignore
)
if notifications:
notifications.write({
'notification_status': notification_status,
'failure_type': failure_type,
'failure_reason': failure_reason,
})
if not self.env.context.get('sms_skip_msg_notification'):
notifications.mail_message_id._notify_message_notification_update()