mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-21 04:42:01 +02:00
19.0 vanilla
This commit is contained in:
parent
5df8c07b59
commit
daa394e8b0
2114 changed files with 564841 additions and 299642 deletions
|
|
@ -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
|
||||
|
|
|
|||
35
odoo-bringout-oca-ocb-sms/sms/models/iap_account.py
Normal file
35
odoo-bringout-oca-ocb-sms/sms/models/iap_account.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
])]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
11
odoo-bringout-oca-ocb-sms/sms/models/res_company.py
Normal file
11
odoo-bringout-oca-ocb-sms/sms/models/res_company.py
Normal 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
|
||||
|
|
@ -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']
|
||||
|
|
@ -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."),
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
83
odoo-bringout-oca-ocb-sms/sms/models/sms_tracker.py
Normal file
83
odoo-bringout-oca-ocb-sms/sms/models/sms_tracker.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue