summaryrefslogtreecommitdiff
path: root/addons/sms/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sms/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sms/models')
-rw-r--r--addons/sms/models/__init__.py14
-rw-r--r--addons/sms/models/ir_actions.py46
-rw-r--r--addons/sms/models/ir_model.py41
-rw-r--r--addons/sms/models/mail_followers.py24
-rw-r--r--addons/sms/models/mail_message.py55
-rw-r--r--addons/sms/models/mail_notification.py21
-rw-r--r--addons/sms/models/mail_thread.py349
-rw-r--r--addons/sms/models/mail_thread_phone.py16
-rw-r--r--addons/sms/models/res_partner.py21
-rw-r--r--addons/sms/models/sms_api.py58
-rw-r--r--addons/sms/models/sms_sms.py143
-rw-r--r--addons/sms/models/sms_template.py66
12 files changed, 854 insertions, 0 deletions
diff --git a/addons/sms/models/__init__.py b/addons/sms/models/__init__.py
new file mode 100644
index 00000000..d7153098
--- /dev/null
+++ b/addons/sms/models/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_actions
+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 res_partner
+from . import sms_api
+from . import sms_sms
+from . import sms_template
diff --git a/addons/sms/models/ir_actions.py b/addons/sms/models/ir_actions.py
new file mode 100644
index 00000000..48c71a9d
--- /dev/null
+++ b/addons/sms/models/ir_actions.py
@@ -0,0 +1,46 @@
+# -*- 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', ondelete='set null',
+ domain="[('model_id', '=', model_id)]",
+ )
+ sms_mass_keep_log = fields.Boolean('Log as Note', default=True)
+
+ @api.constrains('state', 'model_id')
+ def _check_sms_capability(self):
+ for action in self:
+ if action.state == 'sms' and not action.model_id.is_mail_thread:
+ raise ValidationError(_("Sending SMS can only be done on a 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='mass',
+ default_template_id=self.sms_template_id.id,
+ default_mass_keep_log=self.sms_mass_keep_log,
+ ).create({})
+ composer.action_send_sms()
+ return False
diff --git a/addons/sms/models/ir_model.py b/addons/sms/models/ir_model.py
new file mode 100644
index 00000000..1b1db6a9
--- /dev/null
+++ b/addons/sms/models/ir_model.py
@@ -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)]
diff --git a/addons/sms/models/mail_followers.py b/addons/sms/models/mail_followers.py
new file mode 100644
index 00000000..76be79b6
--- /dev/null
+++ b/addons/sms/models/mail_followers.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class Followers(models.Model):
+ _inherit = ['mail.followers']
+
+ def _get_recipient_data(self, records, message_type, subtype_id, pids=None, cids=None):
+ if message_type == 'sms':
+ if pids is None:
+ sms_pids = records._sms_get_default_partners().ids
+ else:
+ sms_pids = pids
+ res = super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids, cids=cids)
+ new_res = []
+ for pid, cid, pactive, pshare, ctype, notif, groups in res:
+ if pid and pid in sms_pids:
+ notif = 'sms'
+ new_res.append((pid, cid, pactive, pshare, ctype, notif, groups))
+ return new_res
+ else:
+ return super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids, cids=cids)
diff --git a/addons/sms/models/mail_message.py b/addons/sms/models/mail_message.py
new file mode 100644
index 00000000..cd827d67
--- /dev/null
+++ b/addons/sms/models/mail_message.py
@@ -0,0 +1,55 @@
+# -*- 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',
+ help='Has 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):
+ """ 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()
+ 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
diff --git a/addons/sms/models/mail_notification.py b/addons/sms/models/mail_notification.py
new file mode 100644
index 00000000..9c397e2f
--- /dev/null
+++ b/addons/sms/models/mail_notification.py
@@ -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': 'set default'})
+ sms_id = fields.Many2one('sms.sms', string='SMS', index=True, ondelete='set null')
+ sms_number = fields.Char('SMS Number')
+ 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')
+ ])
diff --git a/addons/sms/models/mail_thread.py b/addons/sms/models/mail_thread.py
new file mode 100644
index 00000000..45bd8643
--- /dev/null
+++ b/addons/sms/models/mail_thread.py
@@ -0,0 +1,349 @@
+# -*- 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.tools import html2plaintext, plaintext2html
+
+_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._cr.execute(""" SELECT msg.res_id, COUNT(msg.res_id) FROM mail_message msg
+ RIGHT JOIN mail_message_res_partner_needaction_rel rel
+ ON rel.mail_message_id = msg.id AND rel.notification_type = 'sms' AND rel.notification_status in ('exception')
+ WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification'
+ GROUP BY msg.res_id""",
+ (self.env.user.partner_id.id, self._name, 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)]
+
+ 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
+
+ def _message_sms_schedule_mass(self, body='', template=False, active_domain=None, **composer_values):
+ """ Shortcut method to schedule a mass sms sending on a recordset.
+
+ :param template: an optional sms.template record;
+ :param active_domain: bypass self.ids and apply composer on active_domain
+ instead;
+ """
+ composer_context = {
+ 'default_res_model': self._name,
+ 'default_composition_mode': 'mass',
+ 'default_template_id': template.id if template else False,
+ 'default_body': body if body and not template else False,
+ }
+ if active_domain is not None:
+ composer_context['default_use_active_domain'] = True
+ composer_context['default_active_domain'] = repr(active_domain)
+ else:
+ composer_context['default_res_ids'] = self.ids
+
+ 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 (jinja-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_record_by_sms``;
+ :param sms_pid_to_number: see ``_notify_record_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=plaintext2html(html2plaintext(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_record_by_sms(message, recipients_data, msg_vals=msg_vals, **kwargs)
+ return recipients_data
+
+ def _notify_record_by_sms(self, message, recipients_data, msg_vals=False,
+ sms_numbers=None, sms_pid_to_number=None,
+ check_existing=False, put_in_queue=False, **kwargs):
+ """ Notification method: by SMS.
+
+ :param message: mail.message record to notify;
+ :param recipients_data: see ``_notify_thread``;
+ :param msg_vals: see ``_notify_thread``;
+
+ :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 check_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 = msg_vals['body'] if msg_vals and msg_vals.get('body') else message.body
+ sms_base_vals = {
+ 'body': html2plaintext(body),
+ 'mail_message_id': message.id,
+ 'state': 'outgoing',
+ }
+
+ # notify from computed recipients_data (followers, specific recipients)
+ partners_data = [r for r in recipients_data['partners'] 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()
+ ]
+ sms_create_vals += [dict(
+ sms_base_vals,
+ partner_id=False,
+ number=n,
+ state='outgoing' if n else 'error',
+ error_code='' if n else 'sms_number_missing',
+ ) for n in tocreate_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 check_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 = [{
+ '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.error_code,
+ } 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
diff --git a/addons/sms/models/mail_thread_phone.py b/addons/sms/models/mail_thread_phone.py
new file mode 100644
index 00000000..b886a0f1
--- /dev/null
+++ b/addons/sms/models/mail_thread_phone.py
@@ -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 _phone_get_number_fields(self):
+ """ Add fields coming from sms implementation. """
+ sms_fields = self._sms_get_number_fields()
+ res = super(PhoneMixin, self)._phone_get_number_fields()
+ for fname in (f for f in res if f not in sms_fields):
+ sms_fields.append(fname)
+ return sms_fields
diff --git a/addons/sms/models/res_partner.py b/addons/sms/models/res_partner.py
new file mode 100644
index 00000000..3159cb98
--- /dev/null
+++ b/addons/sms/models/res_partner.py
@@ -0,0 +1,21 @@
+# -*- 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 = ['res.partner', 'mail.thread.phone']
+
+ def _sms_get_default_partners(self):
+ """ Override of mail.thread method.
+ SMS recipients on partners are the partners themselves.
+ """
+ return self
+
+ 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. """
+ # TDE note: should override _phone_get_number_fields but ok as sms override it
+ return ['mobile', 'phone']
diff --git a/addons/sms/models/sms_api.py b/addons/sms/models/sms_api.py
new file mode 100644
index 00000000..b1fa69da
--- /dev/null
+++ b/addons/sms/models/sms_api.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, 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):
+ 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)
diff --git a/addons/sms/models/sms_sms.py b/addons/sms/models/sms_sms.py
new file mode 100644
index 00000000..a1c197a6
--- /dev/null
+++ b/addons/sms/models/sms_sms.py
@@ -0,0 +1,143 @@
+# -*- 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)
+ error_code = 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'),
+ ], copy=False)
+
+ def send(self, delete_all=False, auto_commit=False, raise_exception=False):
+ """ Main API method to send SMS.
+
+ :param delete_all: delete all SMS (sent or not); otherwise delete only
+ sent SMS;
+ :param auto_commit: commit after each batch of SMS;
+ :param raise_exception: raise if there is an issue contacting IAP;
+ """
+ for batch_ids in self._split_batch():
+ self.browse(batch_ids)._send(delete_all=delete_all, raise_exception=raise_exception)
+ # auto-commit if asked except in testing mode
+ if auto_commit is True and not getattr(threading.currentThread(), 'testing', False):
+ self._cr.commit()
+
+ def cancel(self):
+ self.state = 'canceled'
+
+ @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.currentThread(), 'testing', False)
+ res = self.browse(ids).send(delete_all=False, 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, delete_all=False, 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], delete_all=delete_all)
+ else:
+ _logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, iap_results)
+ self._postprocess_iap_sent_sms(iap_results, delete_all=delete_all)
+
+ def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, delete_all=False):
+ if delete_all:
+ todelete_sms_ids = [item['res_id'] for item in iap_results]
+ else:
+ 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 delete_all:
+ self.env['sms.sms'].sudo().browse(sms_ids).write({
+ 'state': 'error',
+ 'error_code': self.IAP_TO_SMS_STATE[state],
+ })
+ 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()
diff --git a/addons/sms/models/sms_template.py b/addons/sms/models/sms_template.py
new file mode 100644
index 00000000..be402f02
--- /dev/null
+++ b/addons/sms/models/sms_template.py
@@ -0,0 +1,66 @@
+# -*- 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']
+ _description = 'SMS Templates'
+
+ @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")
+
+ @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