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,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