diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sms/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sms/models')
| -rw-r--r-- | addons/sms/models/__init__.py | 14 | ||||
| -rw-r--r-- | addons/sms/models/ir_actions.py | 46 | ||||
| -rw-r--r-- | addons/sms/models/ir_model.py | 41 | ||||
| -rw-r--r-- | addons/sms/models/mail_followers.py | 24 | ||||
| -rw-r--r-- | addons/sms/models/mail_message.py | 55 | ||||
| -rw-r--r-- | addons/sms/models/mail_notification.py | 21 | ||||
| -rw-r--r-- | addons/sms/models/mail_thread.py | 349 | ||||
| -rw-r--r-- | addons/sms/models/mail_thread_phone.py | 16 | ||||
| -rw-r--r-- | addons/sms/models/res_partner.py | 21 | ||||
| -rw-r--r-- | addons/sms/models/sms_api.py | 58 | ||||
| -rw-r--r-- | addons/sms/models/sms_sms.py | 143 | ||||
| -rw-r--r-- | addons/sms/models/sms_template.py | 66 |
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 |
