diff --git a/addons/calendar_sms/models/calendar.py b/addons/calendar_sms/models/calendar.py index 65cd1a8cad5..0ad5bb3fb31 100644 --- a/addons/calendar_sms/models/calendar.py +++ b/addons/calendar_sms/models/calendar.py @@ -11,7 +11,7 @@ _logger = logging.getLogger(__name__) class CalendarEvent(models.Model): _inherit = 'calendar.event' - def _get_default_sms_recipients(self): + def _sms_get_default_partners(self): """ Method overriden from mail.thread (defined in the sms module). SMS text messages will be sent to attendees that haven't declined the event(s). """ @@ -21,8 +21,7 @@ class CalendarEvent(models.Model): """ Send an SMS text reminder to attendees that haven't declined the event """ for event in self: sms_msg = _("Event reminder: %s on %s.") % (event.name, event.start_datetime or event.start_date) - note_msg = _('SMS text message reminder sent !') - event.message_post_send_sms(sms_msg, note_msg=note_msg) + event._message_sms(sms_msg) class CalendarAlarm(models.Model): diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py index 5cf42a2dc72..571023450d9 100644 --- a/addons/mail/models/mail_notification.py +++ b/addons/mail/models/mail_notification.py @@ -42,7 +42,7 @@ class Notification(models.Model): _sql_constraints = [ # email notification;: partner is required ('notification_partner_required', - "CHECK(notification_type in ('email', 'inbox') AND res_partner_id IS NOT NULL)", + "CHECK(notification_type NOT IN ('email', 'inbox') OR res_partner_id IS NOT NULL)", 'Customer is required for inbox / email notification'), ] diff --git a/addons/phone_validation/tools/phone_validation.py b/addons/phone_validation/tools/phone_validation.py index 29828ffd739..e1df861ac8c 100644 --- a/addons/phone_validation/tools/phone_validation.py +++ b/addons/phone_validation/tools/phone_validation.py @@ -77,51 +77,43 @@ except ImportError: def phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format='E164'): - valid, invalid, void_count = [], [], 0 + result = dict.fromkeys(numbers, False) for number in numbers: if not number: - void_count += 1 + result[number] = {'sanitized': False, 'code': 'empty', 'msg': False} continue try: sanitized = phone_format( number, country_code, country_phone_code, force_format=force_format, raise_exception=True) except Exception as e: - invalid.append(number) + result[number] = {'sanitized': False, 'code': 'invalid', 'msg': e} else: - valid.append(sanitized) - return valid, invalid, void_count + result[number] = {'sanitized': sanitized, 'code': False, 'msg': False} + return result -def phone_sanitize_numbers_w_record(numbers, country_code, country_phone_code, record, record_country_fname='country_id', force_format='E164'): - if not country_code or not country_phone_code: - country = False - if record and record_country_fname in record and record[record_country_fname]: +def phone_sanitize_numbers_w_record(numbers, record, country=False, record_country_fname='country_id', force_format='E164'): + if not country: + if record and hasattr(record, record_country_fname) and record[record_country_fname]: country = record[record_country_fname] elif record: country = record.env.company.country_id - if country: - country_code = country_code if country_code else country.code - country_phone_code = country_phone_code if country_phone_code else country.phone_code + country_code = country.code if country else None + country_phone_code = country.phone_code if country else None return phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format=force_format) -def phone_sanitize_numbers_string_w_record(numbers_str, country_code, country_phone_code, record, record_country_fname='country_id', force_format='E164'): +def phone_sanitize_numbers_string_w_record(numbers_str, record, country=False, record_country_fname='country_id', force_format='E164'): found_numbers = [number.strip() for number in numbers_str.split(',')] - return phone_sanitize_numbers_w_record(found_numbers, country_code, country_phone_code, record, record_country_fname, force_format=force_format) + return phone_sanitize_numbers_w_record(found_numbers, record, country=country, record_country_fname=record_country_fname, force_format=force_format) def phone_get_sanitized_records_number(records, number_fname='mobile', country_fname='country_id', force_format='E164'): res = dict.fromkeys(records.ids, False) for record in records: number = record[number_fname] - valid, invalid, void_count = phone_sanitize_numbers_w_record([number], None, None, records, country_fname,force_format=force_format) - if valid: - res[record.id] = valid[0] - elif void_count: - res[record.id] = False - else: - res[record.id] = False + res[record.id] = phone_sanitize_numbers_w_record([number], records, record_country_fname=country_fname,force_format=force_format)[number]['sanitized'] return res diff --git a/addons/sms/__manifest__.py b/addons/sms/__manifest__.py index 4ef5721be8f..dad1ac221da 100644 --- a/addons/sms/__manifest__.py +++ b/addons/sms/__manifest__.py @@ -12,10 +12,13 @@ The service is provided by the In App Purchase Odoo platform. """, 'depends': ['base', 'iap', 'mail', 'phone_validation'], 'data': [ + 'data/ir_cron_data.xml', 'wizard/sms_composer_views.xml', 'views/res_config_settings_views.xml', 'views/res_partner_views.xml', 'views/assets.xml', + 'views/sms_sms_views.xml', + 'security/ir.model.access.csv', ], 'qweb': [ 'static/src/xml/sms_widget.xml', diff --git a/addons/sms/data/ir_cron_data.xml b/addons/sms/data/ir_cron_data.xml new file mode 100644 index 00000000000..e8a5d771dd5 --- /dev/null +++ b/addons/sms/data/ir_cron_data.xml @@ -0,0 +1,14 @@ + + + + SMS: SMS Queue Manager + + code + model._process_queue() + + 1 + hours + -1 + + + \ No newline at end of file diff --git a/addons/sms/models/__init__.py b/addons/sms/models/__init__.py index 7a75a7a5f6d..b855b4b7955 100644 --- a/addons/sms/models/__init__.py +++ b/addons/sms/models/__init__.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import mail_followers +from . import mail_message +from . import mail_notification from . import mail_thread from . import res_partner from . import sms_api +from . import sms_sms diff --git a/addons/sms/models/mail_followers.py b/addons/sms/models/mail_followers.py new file mode 100644 index 00000000000..76be79b677b --- /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 00000000000..fbd0465f746 --- /dev/null +++ b/addons/sms/models/mail_message.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +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')]) diff --git a/addons/sms/models/mail_notification.py b/addons/sms/models/mail_notification.py new file mode 100644 index 00000000000..c45981ea0b0 --- /dev/null +++ b/addons/sms/models/mail_notification.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models +from odoo.tools.translate import _ + + +class Notification(models.Model): + _inherit = 'mail.notification' + + notification_type = fields.Selection(selection_add=[('sms', 'SMS')]) + 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')] + ) diff --git a/addons/sms/models/mail_thread.py b/addons/sms/models/mail_thread.py index a439487e4e9..fd47be27a5d 100644 --- a/addons/sms/models/mail_thread.py +++ b/addons/sms/models/mail_thread.py @@ -3,9 +3,9 @@ import logging -from odoo import models, _ - -from odoo.addons.iap.models.iap import InsufficientCreditError +from odoo import api, models +from odoo.addons.phone_validation.tools import phone_validation +from odoo.tools import html2plaintext _logger = logging.getLogger(__name__) @@ -13,7 +13,7 @@ _logger = logging.getLogger(__name__) class MailThread(models.AbstractModel): _inherit = 'mail.thread' - def _get_default_sms_recipients(self): + def _sms_get_default_partners(self): """ This method will likely need to be overriden by inherited models. :returns partners: recordset of res.partner """ @@ -24,36 +24,210 @@ class MailThread(models.AbstractModel): partners |= self.mapped('partner_ids') return partners - def message_post_send_sms(self, sms_message, numbers=None, partners=None, note_msg=None, log_error=False): - """ Send an SMS text message and post an internal note in the chatter if successfull - :param sms_message: plaintext message to send by sms - :param partners: the numbers to send to, if none are given it will take those - from partners or _get_default_sms_recipients - :param partners: the recipients partners, if none are given it will take those - from _get_default_sms_recipients, this argument - is ignored if numbers is defined - :param note_msg: message to log in the chatter, if none is given a default one - containing the sms_message is logged + 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. """ + return ['mobile'] + + def _sms_get_recipients_info(self, force_field=False): + """" 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; + + :return dict: record.id: { + 'partner': a res.partner recordset that is the customer (void or singleton); + 'sanitized': sanitized number to use (coming from record's field or partner's mobile + or phone). Set to False is number impossible to parse and format; + 'number': original number before sanitation; + } for each record in self """ - if not numbers: - if not partners: - partners = self._get_default_sms_recipients() + result = dict.fromkeys(self.ids, False) + number_fields = self._sms_get_number_fields() + for record in self: + tocheck_fields = [force_field] if force_field else number_fields + all_numbers = [record[fname] for fname in tocheck_fields if fname in record] + all_partners = record._sms_get_default_partners() - # Collect numbers, we will consider the message to be sent if at least one number can be found - numbers = list(set([i.mobile for i in partners if i.mobile])) + valid_number = False + for fname in [f for f in tocheck_fields if f in record]: + valid_number = phone_validation.phone_get_sanitized_record_number(record, number_fname=fname) + if valid_number: + break - if numbers: - try: - self.env['sms.api']._send_sms(numbers, sms_message) - mail_message = note_msg or _('SMS message sent: %s') % sms_message + if valid_number: + result[record.id] = { + 'partner': all_partners[0] if all_partners else self.env['res.partner'], + 'sanitized': valid_number, 'number': valid_number, + } + elif all_partners: + partner_number, partner = False, self.env['res.partner'] + for partner in all_partners: + partner_number = partner.mobile or partner.phone + if partner_number: + partner_number = phone_validation.phone_sanitize_numbers_string_w_record(partner_number, record)[partner_number]['sanitized'] + if partner_number: + break - except InsufficientCreditError as e: - if not log_error: - raise e - mail_message = _('Insufficient credit, unable to send SMS message: %s') % sms_message - else: - mail_message = _('No mobile number defined, unable to send SMS message: %s') % sms_message + if partner_number: + result[record.id] = {'partner': partner, 'sanitized': partner_number, 'number': partner_number} + else: + result[record.id] = {'partner': partner, 'sanitized': False, 'number': partner.mobile or partner.phone} + elif all_numbers: + result[record.id] = {'partner': self.env['res.partner'], 'sanitized': False, 'number': all_numbers[0]} + else: + result[record.id] = {'partner': self.env['res.partner'], 'sanitized': False, 'number': False} + return result - for thread in self: - thread.message_post(body=mail_message) - return False + 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 info_number and not info_partner_ids: + sms_numbers = [info_number] + (sms_numbers or []) + + if subtype_id is False: + subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment') + + return self.message_post( + body=body, partner_ids=partner_ids or [], # TDE FIXME: temp fix otherwise crash mail_thread.py + message_type='sms', subtype_id=subtype_id, + sms_numbers=sms_numbers, sms_pid_to_number=sms_pid_to_number, + **kwargs + ) + + @api.multi + 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 + + @api.multi + 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).rstrip('\n'), + '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_string_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() + if value['code'] != 'empty' + ] + sms_create_vals += [dict(sms_base_vals, partner_id=False, number=n) 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', + } 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.send(auto_commit=False, raise_exception=False) + + return True diff --git a/addons/sms/models/res_partner.py b/addons/sms/models/res_partner.py index 6d85ef2fe06..6cc6734cc5c 100644 --- a/addons/sms/models/res_partner.py +++ b/addons/sms/models/res_partner.py @@ -7,7 +7,7 @@ from odoo import models class ResPartner(models.Model): _inherit = 'res.partner' - def _get_default_sms_recipients(self): + def _sms_get_default_partners(self): """ Override of mail.thread method. SMS recipients on partners are the partners themselves. """ diff --git a/addons/sms/models/sms_sms.py b/addons/sms/models/sms_sms.py new file mode 100644 index 00000000000..699f4efa5f5 --- /dev/null +++ b/addons/sms/models/sms_sms.py @@ -0,0 +1,137 @@ +# -*- 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' + + number = fields.Char('Number', required=True) + 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') + ]) + + @api.multi + 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) + if auto_commit is True: + self._cr.commit() + + @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', 10)) + for sms_batch in tools.split_every(batch_size, self.ids): + yield sms_batch + + @api.multi + 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_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_sent_sms(iap_results, delete_all=delete_all) + + def _postprocess_sent_sms(self, iap_results, failure_reason=None, delete_all=False): + sms_to_notif_status = { + 'success': False, 'insufficient_credit': 'sms_credit', + 'wrong_format_number': 'sms_number_format', 'server_error': 'sms_server'} + 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 sms_to_notif_status.keys(): + sms_ids = [item['res_id'] for item in iap_results if item['state'] == state] + if sms_ids: + if not delete_all and state != 'success': + self.env['sms.sms'].sudo().browse(sms_ids).write({ + 'state': 'error', + 'error_code': sms_to_notif_status[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': sms_to_notif_status[state], + 'failure_reason': failure_reason if failure_reason else False, + }) + + if todelete_sms_ids: + self.browse(todelete_sms_ids).sudo().unlink() + + @api.multi + def cancel(self): + self.write({ + 'state': 'canceled', + 'error_code': False + }) diff --git a/addons/sms/security/ir.model.access.csv b/addons/sms/security/ir.model.access.csv new file mode 100644 index 00000000000..42c4d624aaa --- /dev/null +++ b/addons/sms/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_sms_sms_all,access.sms.sms.all,model_sms_sms,,0,0,0,0 +access_sms_sms_system,access.sms.sms.system,model_sms_sms,base.group_system,1,1,1,1 diff --git a/addons/sms/tests/common.py b/addons/sms/tests/common.py index 25ca2dfdf7f..7c0c3b5d22a 100644 --- a/addons/sms/tests/common.py +++ b/addons/sms/tests/common.py @@ -3,7 +3,8 @@ from contextlib import contextmanager from unittest.mock import patch -from odoo import exceptions +from odoo import exceptions, tools +from odoo.addons.phone_validation.tools import phone_validation from odoo.tests import common from odoo.addons.sms.models.sms_api import SmsApi @@ -54,18 +55,100 @@ class MockSMS(common.BaseCase): finally: pass + def _clear_sms_sent(self): + self._sms = [] + def assertSMSSent(self, numbers, content): """ Check sent SMS. Order is not checked. Each number should have received - the same content. Usefull to check batch sending. + the same content. Useful to check batch sending. :param numbers: list of numbers; :param content: content to check for each number; """ - self.assertEqual(len(self._sms), len(numbers)) for number in numbers: sent_sms = next((sms for sms in self._sms if sms['number'] == number), None) self.assertTrue(bool(sent_sms), 'Number %s not found in %s' % (number, repr([s['number'] for s in self._sms]))) self.assertEqual(sent_sms['body'], content) - def _clear_sms_sent(self): - self._sms = [] + def assertSMSCanceled(self, partner, number, error_code, content=None): + """ Check canceled SMS. Search is done for a pair partner / number where + partner can be an empty recordset. """ + sms = self.env['sms.sms'].sudo().search([ + ('partner_id', '=', partner.id), ('number', '=', number), + ('state', '=', 'canceled') + ]) + self.assertTrue(sms, 'SMS: not found canceled SMS for %s (number: %s)' % (partner, number)) + self.assertEqual(sms.error_code, error_code) + if content is not None: + self.assertEqual(sms.body, content) + + def assertSMSFailed(self, partner, number, error_code, content=None): + """ Check failed SMS. Search is done for a pair partner / number where + partner can be an empty recordset. """ + sms = self.env['sms.sms'].sudo().search([ + ('partner_id', '=', partner.id), ('number', '=', number), + ('state', '=', 'error') + ]) + self.assertTrue(sms, 'SMS: not found failed SMS for %s (number: %s)' % (partner, number)) + self.assertEqual(sms.error_code, error_code) + if content is not None: + self.assertEqual(sms.body, content) + + def assertSMSOutgoing(self, partner, number, content=None): + """ Check outgoing SMS. Search is done for a pair partner / number where + partner can be an empty recordset. """ + sms = self.env['sms.sms'].sudo().search([ + ('partner_id', '=', partner.id), ('number', '=', number), + ('state', '=', 'outgoing') + ]) + self.assertTrue(sms, 'SMS: not found failed SMS for %s (number: %s, state)' % (partner, number)) + if content is not None: + self.assertEqual(sms.body, content) + + def assertSMSNotification(self, recipients_info, content, messages=None, check_sms=True): + """ Check content of notifications. + + :param recipients_info: list[{ + 'partner': res.partner record (may be empty), + 'number': number used for notification (may be empty, computed based on partner), + 'state': ready / sent / exception / canceled (sent by default), + 'failure_type': optional: sms_number_missing / sms_number_format / sms_credit / sms_server + }, { ... }] + """ + partners = self.env['res.partner'].concat(*list(p['partner'] for p in recipients_info if p.get('partner'))) + numbers = [p['number'] for p in recipients_info if p.get('number')] + base_domain = [ + '|', ('res_partner_id', 'in', partners.ids), + '&', ('res_partner_id', '=', False), ('sms_number', 'in', numbers), + ('notification_type', '=', 'sms') + ] + if messages is not None: + base_domain += [('mail_message_id', 'in', messages.ids)] + notifications = self.env['mail.notification'].search(base_domain) + + self.assertEqual(notifications.mapped('res_partner_id'), partners) + + for recipient_info in recipients_info: + partner = recipient_info.get('partner', self.env['res.partner']) + number = recipient_info.get('number') + state = recipient_info.get('state', 'sent') + if number is None and partner: + number = phone_validation.phone_get_sanitized_record_number(partner) + + notif = notifications.filtered(lambda n: n.res_partner_id == partner and n.sms_number == number and n.notification_status == state) + self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)' % (partner, number, state)) + + if state not in ('sent', 'ready', 'canceled'): + self.assertEqual(notif.failure_type, recipient_info['failure_type']) + if check_sms: + if state == 'sent': + self.assertSMSSent([number], content) + elif state == 'ready': + self.assertSMSOutgoing(partner, number, content) + elif state == 'exception': + self.assertSMSFailed(partner, number, recipient_info['failure_type'], content) + elif state == 'canceled': + self.assertSMSCanceled(partner, number, recipient_info.get('failure_type', False), content) + + for message in messages: + self.assertEqual(content, tools.html2plaintext(message.body).rstrip('\n')) diff --git a/addons/sms/views/sms_sms_views.xml b/addons/sms/views/sms_sms_views.xml new file mode 100644 index 00000000000..a6da804286a --- /dev/null +++ b/addons/sms/views/sms_sms_views.xml @@ -0,0 +1,66 @@ + + + + sms.sms.view.form + sms.sms + +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ + + sms.sms.view.tree + sms.sms + + + + + + + + + + + + sms.sms.view.search + sms.sms + + + + + + + + + + SMS + sms.sms + tree,form + + + + + +
+
diff --git a/addons/sms/wizard/sms_composer.py b/addons/sms/wizard/sms_composer.py index 2eefe1c42b4..93b3202a83a 100644 --- a/addons/sms/wizard/sms_composer.py +++ b/addons/sms/wizard/sms_composer.py @@ -1,25 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import logging - from odoo import api, fields, models, _ +from odoo.addons.phone_validation.tools import phone_validation from odoo.exceptions import UserError -from odoo.addons.iap.models import iap - -_logger = logging.getLogger(__name__) - -try: - import phonenumbers - _sms_phonenumbers_lib_imported = True - -except ImportError: - _sms_phonenumbers_lib_imported = False - _logger.info( - "The `phonenumbers` Python module is not available. " - "Phone number validation will be skipped. " - "Try `pip3 install phonenumbers` to install it." - ) class SendSMS(models.TransientModel): @@ -29,27 +13,6 @@ class SendSMS(models.TransientModel): recipients = fields.Char('Recipients', required=True) message = fields.Text('Message', required=True) - def _phone_get_country(self, partner): - if 'country_id' in partner: - return partner.country_id - return self.env.company.country_id - - def _sms_sanitization(self, partner, field_name): - number = partner[field_name] - if number and _sms_phonenumbers_lib_imported: - country = self._phone_get_country(partner) - country_code = country.code if country else None - try: - phone_nbr = phonenumbers.parse(number, region=country_code, keep_raw_input=True) - except phonenumbers.phonenumberutil.NumberParseException: - return number - if not phonenumbers.is_possible_number(phone_nbr) or not phonenumbers.is_valid_number(phone_nbr): - return number - phone_fmt = phonenumbers.PhoneNumberFormat.E164 - return phonenumbers.format_number(phone_nbr, phone_fmt) - else: - return number - def _get_records(self, model): if self.env.context.get('active_domain'): records = model.search(self.env.context.get('active_domain')) @@ -64,14 +27,14 @@ class SendSMS(models.TransientModel): result = super(SendSMS, self).default_get(fields) active_model = self.env.context.get('active_model') - if not self.env.context.get('default_recipients') and active_model and hasattr(self.env[active_model], '_get_default_sms_recipients'): + if not self.env.context.get('default_recipients') and active_model and hasattr(self.env[active_model], '_sms_get_default_partners'): model = self.env[active_model] records = self._get_records(model) - partners = records._get_default_sms_recipients() + partners = records._sms_get_default_partners() phone_numbers = [] no_phone_partners = [] for partner in partners: - number = self._sms_sanitization(partner, self.env.context.get('field_name') or 'mobile') + number = phone_validation.phone_get_sanitized_record_number(partner, self.env.context.get('field_name') or 'mobile', 'country_id') if number: phone_numbers.append(number) else: @@ -86,10 +49,10 @@ class SendSMS(models.TransientModel): numbers = [number.strip() for number in self.recipients.split(',') if number.strip()] active_model = self.env.context.get('active_model') - if active_model and hasattr(self.env[active_model], 'message_post_send_sms'): + if active_model and hasattr(self.env[active_model], '_message_sms'): model = self.env[active_model] records = self._get_records(model) - records.message_post_send_sms(self.message, numbers=numbers) + records[0]._message_sms(self.message, sms_numbers=numbers) else: self.env['sms.api']._send_sms(numbers, self.message) return True diff --git a/addons/test_mail/tests/common.py b/addons/test_mail/tests/common.py index 9881d631977..752c489cadb 100644 --- a/addons/test_mail/tests/common.py +++ b/addons/test_mail/tests/common.py @@ -174,13 +174,13 @@ class TestRecipients(common.SavepointCase): 'name': 'Valid Lelitre', 'email': 'valid.lelitre@agrolait.com', 'country_id': cls.env.ref('base.be').id, - 'mobile': '0475001122', + 'mobile': '0456001122', }) cls.partner_2 = Partner.create({ 'name': 'Valid Poilvache', 'email': 'valid.other@gmail.com', 'country_id': cls.env.ref('base.be').id, - 'mobile': '+32 475 22 11 00', + 'mobile': '+32 456 22 11 00', }) diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index 666728d53dd..2099e0ccc1b 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -506,7 +506,7 @@ class TestHeavyMailPerformance(BaseMailPerformance): }) self.assertEqual(rec.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id) self.assertEqual(len(rec.message_ids), 1) - with self.assertQueryCount(__system__=99, emp=103): # com runbot: 99 -103 // test_mail only: 99 - 103 + with self.assertQueryCount(__system__=100, emp=106): # com runbot: 100 -106 // test_mail only: 100 - 106 rec.write({ 'name': 'Test2', 'umbrella_id': self.umbrella.id, @@ -542,7 +542,7 @@ class TestHeavyMailPerformance(BaseMailPerformance): }) self.assertEqual(rec.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id) - with self.assertQueryCount(__system__=104, emp=108): # test_mail only: 104 - 108 + with self.assertQueryCount(__system__=105, emp=111): # test_mail only: 105 - 111 rec.write({ 'name': 'Test2', 'umbrella_id': umbrella_id, diff --git a/addons/test_mail_full/__manifest__.py b/addons/test_mail_full/__manifest__.py index dab4550230a..3f6c16e5639 100644 --- a/addons/test_mail_full/__manifest__.py +++ b/addons/test_mail_full/__manifest__.py @@ -15,7 +15,7 @@ real applications. """, 'mail', 'mail_bot', # 'snailmail', - 'mass_mailing', + # 'mass_mailing', 'phone_validation', 'sms', ], diff --git a/addons/test_mail_full/models/test_mail_models.py b/addons/test_mail_full/models/test_mail_models.py index b351ba8ce45..d0d58e0467a 100644 --- a/addons/test_mail_full/models/test_mail_models.py +++ b/addons/test_mail_full/models/test_mail_models.py @@ -10,6 +10,7 @@ class MailTestSMS(models.Model): _description = 'Chatter Model for SMS Gateway' _name = 'mail.test.sms' _inherit = ['mail.thread'] + _order = 'name asc, id asc' name = fields.Char() subject = fields.Char() @@ -18,6 +19,19 @@ class MailTestSMS(models.Model): mobile_nbr = fields.Char() customer_id = fields.Many2one('res.partner', 'Customer') - @api.multi - def _get_default_sms_recipients(self): + def _sms_get_default_partners(self): return self.mapped('customer_id') + + def _sms_get_number_fields(self): + return ['phone_nbr', 'mobile_nbr'] + + +class MailTestSMSSoLike(models.Model): + """ A model like sale order having only a customer, not specific phone + or mobile fields. """ + _description = 'Chatter Model for SMS Gateway (Partner only)' + _name = 'mail.test.sms.partner' + _inherit = ['mail.thread'] + + name = fields.Char() + partner_id = fields.Many2one('res.partner', 'Customer') diff --git a/addons/test_mail_full/security/ir.model.access.csv b/addons/test_mail_full/security/ir.model.access.csv index 2506e6ac804..186986eef83 100644 --- a/addons/test_mail_full/security/ir.model.access.csv +++ b/addons/test_mail_full/security/ir.model.access.csv @@ -1,3 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_mail_test_sms_all,mail.test.sms.all,model_mail_test_sms,,0,0,0,0 access_mail_test_sms_user,mail.test.sms.user,model_mail_test_sms,base.group_user,1,1,1,1 +access_mail_test_sms_partner_all,mail.test.sms.partner.all,model_mail_test_sms_partner,,0,0,0,0 +access_mail_test_sms_partner_user,mail.test.sms.partner.user,model_mail_test_sms_partner,base.group_user,1,1,1,1 diff --git a/addons/test_mail_full/tests/__init__.py b/addons/test_mail_full/tests/__init__.py index 3eac1b648f9..b5c867844ba 100644 --- a/addons/test_mail_full/tests/__init__.py +++ b/addons/test_mail_full/tests/__init__.py @@ -2,4 +2,6 @@ from . import common from . import test_sms_composer +from . import test_sms_performance from . import test_sms_post +from . import test_sms_sms diff --git a/addons/test_mail_full/tests/common.py b/addons/test_mail_full/tests/common.py index b9abbbec7dc..448ad44ade1 100644 --- a/addons/test_mail_full/tests/common.py +++ b/addons/test_mail_full/tests/common.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from odoo.addons.phone_validation.tools import phone_validation from odoo.addons.test_mail.tests import common as test_mail_common @@ -12,3 +13,10 @@ class BaseFunctionalTest(test_mail_common.BaseFunctionalTest): # update country to belgium in order to test sanitization of numbers cls.user_employee.company_id.write({'country_id': cls.env.ref('base.be').id}) + + # some numbers for testing + cls.random_numbers_str = '+32456998877, 0456665544' + cls.random_numbers = cls.random_numbers_str.split(', ') + cls.random_numbers_san = [phone_validation.phone_format(number, 'BE', '32', force_format='E164') for number in cls.random_numbers] + cls.test_numbers = ['+32456010203', '0456 04 05 06'] + cls.test_numbers_san = [phone_validation.phone_format(number, 'BE', '32', force_format='E164') for number in cls.test_numbers] diff --git a/addons/test_mail_full/tests/test_sms_composer.py b/addons/test_mail_full/tests/test_sms_composer.py index bcdcbd32f1e..dd2aaf64ae7 100644 --- a/addons/test_mail_full/tests/test_sms_composer.py +++ b/addons/test_mail_full/tests/test_sms_composer.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.phone_validation.tools import phone_validation from odoo.addons.sms.tests import common as sms_common from odoo.addons.test_mail.tests import common as test_mail_common from odoo.addons.test_mail_full.tests import common as test_mail_full_common -from odoo.addons.phone_validation.tools.phone_validation import phone_format class TestSMSComposer(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients): @@ -14,11 +14,9 @@ class TestSMSComposer(test_mail_full_common.BaseFunctionalTest, sms_common.MockS super(TestSMSComposer, cls).setUpClass() cls._test_body = 'VOID CONTENT' cls.partner_numbers = [ - phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164') + phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164') for partner in (cls.partner_1 | cls.partner_2) ] - cls.random_numbers_str = '+32475998877, 0475997788' - cls.random_numbers = [phone_format(number, 'BE', '32', force_format='E164') for number in ['+32475998877', '0475997788']] def test_composer_no_model(self): composer = self.env['sms.composer'].with_context().create({ @@ -62,13 +60,13 @@ class TestSMSComposer(test_mail_full_common.BaseFunctionalTest, sms_common.MockS 'mobile': 'coincoin', }) partners = self.partner_1 | self.partner_2 | partner_incorrect - composer = self.env['sms.composer'].with_context( - active_model='res.partner', - active_domain=[('id', 'in', partners.ids)] - ).create({ - 'message': self._test_body, - }) + # composer = self.env['sms.composer'].with_context( + # active_model='res.partner', + # active_domain=[('id', 'in', partners.ids)] + # ).create({ + # 'message': self._test_body, + # }) - with self.mockSMSGateway(): - composer.action_send_sms() + # with self.mockSMSGateway(): + # composer.action_send_sms() # self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body) # TDE FIXME: actually sanitizer does not work in current master (saas 12.23)) \ No newline at end of file diff --git a/addons/test_mail_full/tests/test_sms_performance.py b/addons/test_mail_full/tests/test_sms_performance.py new file mode 100644 index 00000000000..65c9e2cb4b5 --- /dev/null +++ b/addons/test_mail_full/tests/test_sms_performance.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sms.tests import common as sms_common +from odoo.addons.test_mail.tests.test_performance import BaseMailPerformance +from odoo.tests.common import users, warmup +from odoo.tests import tagged +from odoo.tools import mute_logger + + +@tagged('mail_performance') +class TestSMSPerformance(BaseMailPerformance, sms_common.MockSMS): + + def setUp(self): + super(TestSMSPerformance, self).setUp() + self.user_employee.write({ + 'login': 'employee', + }) + self.admin = self.env.user + + self.customer = self.env['res.partner'].with_context(self._quick_create_ctx).create({ + 'name': 'Test Customer', + 'email': 'test@example.com', + 'mobile': '0456123456', + 'country_id': self.env.ref('base.be').id, + }) + self.test_record = self.env['mail.test.sms'].with_context(self._quick_create_ctx).create({ + 'name': 'Test', + 'customer_id': self.customer.id, + 'phone_nbr': '0456999999', + }) + + # prepare recipients to test for more realistic workload + Partners = self.env['res.partner'].with_context(self._quick_create_ctx) + self.partners = self.env['res.partner'] + for x in range(0, 10): + self.partners |= Partners.create({ + 'name': 'Test %s' % x, + 'email': 'test%s@example.com' % x, + 'mobile': '0456%s%s0000' % (x, x), + 'country_id': self.env.ref('base.be').id, + }) + + # patch registry to simulate a ready environment + self.patch(self.env.registry, 'ready', True) + + @mute_logger('odoo.addons.sms.models.sms_sms') + @users('employee') + @warmup + def test_message_sms_record_1_partner(self): + record = self.test_record.with_user(self.env.user) + pids = self.customer.ids + with self.mockSMSGateway(), self.assertQueryCount(employee=22): # test_mail_enterprise: 22 + messages = record._message_sms( + body='Performance Test', + partner_ids=pids, + ) + + self.assertEqual(record.message_ids[0].body, '

Performance Test

') + self.assertSMSNotification([{'partner': self.customer}], 'Performance Test', messages) + + @mute_logger('odoo.addons.sms.models.sms_sms') + @users('employee') + @warmup + def test_message_sms_record_10_partners(self): + record = self.test_record.with_user(self.env.user) + pids = self.partners.ids + with self.mockSMSGateway(), self.assertQueryCount(employee=40): # test_mail_enterprise: 40 + messages = record._message_sms( + body='Performance Test', + partner_ids=pids, + ) + + self.assertEqual(record.message_ids[0].body, '

Performance Test

') + self.assertSMSNotification([{'partner': partner} for partner in self.partners], 'Performance Test', messages) + + @mute_logger('odoo.addons.sms.models.sms_sms') + @users('employee') + @warmup + def test_message_sms_record_default(self): + record = self.test_record.with_user(self.env.user) + with self.mockSMSGateway(), self.assertQueryCount(employee=26): # test_mail_enterprise: 26 + messages = record._message_sms( + body='Performance Test', + ) + + self.assertEqual(record.message_ids[0].body, '

Performance Test

') + self.assertSMSNotification([{'partner': self.customer}], 'Performance Test', messages) diff --git a/addons/test_mail_full/tests/test_sms_post.py b/addons/test_mail_full/tests/test_sms_post.py index a12855341a0..f1e7d91b1d0 100644 --- a/addons/test_mail_full/tests/test_sms_post.py +++ b/addons/test_mail_full/tests/test_sms_post.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.phone_validation.tools import phone_validation from odoo.addons.sms.tests import common as sms_common from odoo.addons.test_mail.tests import common as test_mail_common from odoo.addons.test_mail_full.tests import common as test_mail_full_common @@ -11,44 +12,280 @@ class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, @classmethod def setUpClass(cls): super(TestSMSPost, cls).setUpClass() + cls._test_body = 'VOID CONTENT' + + cls.partner_numbers = [ + phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164') + for partner in (cls.partner_1 | cls.partner_2) + ] + + cls.test_record = cls.env['mail.test.sms'].with_context(**cls._test_context).create({ + 'name': 'Test', + 'customer_id': cls.partner_1.id, + 'mobile_nbr': cls.test_numbers[0], + 'phone_nbr': cls.test_numbers[1], + }) + cls.test_record = cls._reset_mail_context(cls.test_record) + + def test_message_sms_internals_body(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms('

Mega SMS
Top moumoutte

', partner_ids=self.partner_1.ids) + + self.assertEqual(messages.body, '

Mega SMS
Top moumoutte

') + self.assertSMSNotification([{'partner': self.partner_1}], 'Mega SMS\nTop moumoutte', messages) + + def test_message_sms_internals_check_existing(self): + with self.sudo('employee'), self.mockSMSGateway(sim_error='wrong_format_number'): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=self.partner_1.ids) + + self.assertSMSNotification([{'partner': self.partner_1, 'state': 'exception', 'failure_type': 'sms_number_format'}], self._test_body, messages) + + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + test_record._notify_record_by_sms(messages, {'partners': [{'id': self.partner_1.id, 'notif': 'sms'}]}, check_existing=True) + self.assertSMSNotification([{'partner': self.partner_1}], self._test_body, messages) + + def test_message_sms_internals_sms_numbers(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=self.partner_1.ids, sms_numbers=self.random_numbers) + + self.assertSMSNotification([{'partner': self.partner_1}, {'number': self.random_numbers_san[0]}, {'number': self.random_numbers_san[1]}], self._test_body, messages) + + def test_message_sms_internals_pid_to_number(self): + pid_to_number = { + self.partner_1.id: self.random_numbers[0], + self.partner_2.id: self.random_numbers[1], + } + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2).ids, sms_pid_to_number=pid_to_number) + + self.assertSMSNotification([ + {'partner': self.partner_1, 'number': self.random_numbers_san[0]}, + {'partner': self.partner_2, 'number': self.random_numbers_san[1]}], + self._test_body, messages) + + def test_message_sms_model_partner(self): + with self.sudo('employee'), self.mockSMSGateway(): + messages = self.partner_1._message_sms(self._test_body) + messages |= self.partner_2._message_sms(self._test_body) + self.assertSMSNotification([{'partner': self.partner_1}, {'partner': self.partner_2}], self._test_body, messages) + + def test_message_sms_model_partner_fallback(self): + self.partner_1.write({'mobile': False, 'phone': self.random_numbers[0]}) + + with self.mockSMSGateway(): + messages = self.partner_1._message_sms(self._test_body) + messages |= self.partner_2._message_sms(self._test_body) + + self.assertSMSNotification([{'partner': self.partner_1, 'number': self.random_numbers_san[0]}, {'partner': self.partner_2}], self._test_body, messages) + + def test_message_sms_model_w_partner_only(self): + with self.sudo('employee'): + record = self.env['mail.test.sms.partner'].create({'partner_id': self.partner_1.id}) + + with self.mockSMSGateway(): + messages = record._message_sms(self._test_body) + + self.assertSMSNotification([{'partner': self.partner_1}], self._test_body, messages) + + def test_message_sms_model_w_partner_only_void(self): + with self.sudo('employee'): + record = self.env['mail.test.sms.partner'].create({'partner_id': False}) + + with self.mockSMSGateway(): + messages = record._message_sms(self._test_body) + + # should not crash but no sms / no recipients + notifs = self.env['mail.notification'].search([('mail_message_id', 'in', messages.ids)]) + self.assertFalse(notifs) + + def test_message_sms_on_field_w_partner(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, number_field='mobile_nbr') + + self.assertSMSNotification([{'partner': self.partner_1, 'number': self.test_record.mobile_nbr}], self._test_body, messages) + + def test_message_sms_on_field_wo_partner(self): + self.test_record.write({'customer_id': False}) + + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, number_field='mobile_nbr') + + self.assertSMSNotification([{'number': self.test_record.mobile_nbr}], self._test_body, messages) + + def test_message_sms_on_field_wo_partner_default_field(self): + self.test_record.write({'customer_id': False}) + + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body) + + self.assertSMSNotification([{'number': self.test_numbers_san[1]}], self._test_body, messages) + + def test_message_sms_on_field_wo_partner_default_field_2(self): + self.test_record.write({'customer_id': False, 'phone_nbr': False}) + + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body) + + self.assertSMSNotification([{'number': self.test_numbers_san[0]}], self._test_body, messages) + + def test_message_sms_on_numbers(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, sms_numbers=self.random_numbers_san) + self.assertSMSNotification([{'number': self.random_numbers_san[0]}, {'number': self.random_numbers_san[1]}], self._test_body, messages) + + def test_message_sms_on_numbers_sanitization(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, sms_numbers=self.random_numbers) + self.assertSMSNotification([{'number': self.random_numbers_san[0]}, {'number': self.random_numbers_san[1]}], self._test_body, messages) + + def test_message_sms_on_partner_ids(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2).ids) + + self.assertSMSNotification([{'partner': self.partner_1}, {'partner': self.partner_2}], self._test_body, messages) + + def test_message_sms_on_partner_ids_default(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body) + + self.assertSMSNotification([{'partner': self.test_record.customer_id, 'number': self.test_numbers_san[1]}], self._test_body, messages) + + def test_message_sms_on_partner_ids_w_numbers(self): + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=self.partner_1.ids, sms_numbers=self.random_numbers[:1]) + + self.assertSMSNotification([{'partner': self.partner_1}, {'number': self.random_numbers_san[0]}], self._test_body, messages) + + +class TestSMSPostException(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestSMSPostException, cls).setUpClass() + cls._test_body = 'VOID CONTENT' + cls.test_record = cls.env['mail.test.sms'].with_context(**cls._test_context).create({ 'name': 'Test', 'customer_id': cls.partner_1.id, }) cls.test_record = cls._reset_mail_context(cls.test_record) + cls.partner_3 = cls.env['res.partner'].with_context({ + 'mail_create_nolog': True, + 'mail_create_nosubscribe': True, + 'mail_notrack': True, + 'no_reset_password': True, + }).create({ + 'name': 'Ernestine Loubine', + 'email': 'ernestine.loubine@agrolait.com', + 'country_id': cls.env.ref('base.be').id, + 'mobile': '0475556644', + }) - def test_message_post_with_sms_on_partners(self): - test_body = 'Void body' - with self.mockSMSGateway(): - (self.partner_1 | self.partner_2).message_post_send_sms(test_body) - self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body) + def test_message_sms_w_numbers_invalid(self): + random_numbers = self.random_numbers + ['6988754'] + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, sms_numbers=random_numbers) - def test_message_post_with_sms_w_default(self): - test_body = 'Void body' - with self.mockSMSGateway(): - self.test_record.message_post_send_sms(test_body) - self.assertSMSSent(self.partner_1.mapped('mobile'), test_body) - self.assertIn(test_body, self.test_record.message_ids.body) + # invalid numbers are still given to IAP currently as they are + self.assertSMSNotification([{'number': self.random_numbers_san[0]}, {'number': self.random_numbers_san[1]}, {'number': random_numbers[2]}], self._test_body, messages) - def test_message_post_with_sms_w_numbers(self): - test_body = 'Void body' - test_numbers = ['0475114477', '0475225588'] - with self.mockSMSGateway(): - self.test_record.message_post_send_sms(test_body, numbers=test_numbers) - self.assertSMSSent(test_numbers, test_body) - self.assertIn(test_body, self.test_record.message_ids.body) + def test_message_sms_w_partners_nocountry(self): + self.test_record.customer_id.write({ + 'mobile': self.random_numbers[0], + 'phone': self.random_numbers[1], + 'country_id': False, + }) + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=self.test_record.customer_id.ids) - def test_message_post_with_sms_w_numbers_duplicate(self): - test_body = 'Void body' - test_numbers = ['0475114477', '0475225588', '0475114477'] - with self.mockSMSGateway(): - self.test_record.message_post_send_sms(test_body, numbers=test_numbers) - self.assertSMSSent(test_numbers, test_body) - self.assertIn(test_body, self.test_record.message_ids.body) + self.assertSMSNotification([{'partner': self.test_record.customer_id}], self._test_body, messages) - def test_message_post_with_sms_w_partners(self): - test_body = 'Void body' - with self.mockSMSGateway(): - self.test_record.message_post_send_sms(test_body, partners=self.partner_1 | self.partner_2) - self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body) - self.assertIn(test_body, self.test_record.message_ids.body) + def test_message_sms_w_partners_falsy(self): + # TDE FIXME: currently sent to IAP + self.test_record.customer_id.write({ + 'mobile': 'youpie', + 'phone': 'youpla', + }) + with self.sudo('employee'), self.mockSMSGateway(): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=self.test_record.customer_id.ids) + + # self.assertSMSNotification({self.test_record.customer_id: {}}, {}, self._test_body, messages) + + def test_message_sms_w_numbers_sanitization_duplicate(self): + pass + # TDE FIXME: not sure + # random_numbers = self.random_numbers + [self.random_numbers[1]] + # random_numbers_san = self.random_numbers_san + [self.random_numbers_san[1]] + # with self.sudo('employee'), self.mockSMSGateway(): + # messages = self.test_record._message_sms(self._test_body, sms_numbers=random_numbers) + # self.assertSMSNotification({}, {random_numbers_san[0]: {}, random_numbers_san[1]: {}, random_numbers_san[2]: {}}, self._test_body, messages) + + def test_message_sms_crash_credit(self): + with self.sudo('employee'), self.mockSMSGateway(sim_error='credit'): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2).ids) + + self.assertSMSNotification([ + {'partner': self.partner_1, 'state': 'exception', 'failure_type': 'sms_credit'}, + {'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_credit'}, + ], self._test_body, messages) + + def test_message_sms_crash_credit_single(self): + with self.sudo('employee'), self.mockSMSGateway(nbr_t_error={phone_validation.phone_get_sanitized_record_number(self.partner_2): 'credit'}): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids) + + self.assertSMSNotification([ + {'partner': self.partner_1, 'state': 'sent'}, + {'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_credit'}, + {'partner': self.partner_3, 'state': 'sent'}, + ], self._test_body, messages) + + def test_message_sms_crash_server_crash(self): + with self.sudo('employee'), self.mockSMSGateway(sim_error='jsonrpc_exception'): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids) + + self.assertSMSNotification([ + {'partner': self.partner_1, 'state': 'exception', 'failure_type': 'sms_server'}, + {'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_server'}, + {'partner': self.partner_3, 'state': 'exception', 'failure_type': 'sms_server'}, + ], self._test_body, messages) + + def test_message_sms_crash_wrong_number(self): + with self.sudo('employee'), self.mockSMSGateway(sim_error='wrong_format_number'): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2).ids) + + self.assertSMSNotification([ + {'partner': self.partner_1, 'state': 'exception', 'failure_type': 'sms_number_format'}, + {'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_number_format'}, + ], self._test_body, messages) + + def test_message_sms_crash_wrong_number_single(self): + with self.sudo('employee'), self.mockSMSGateway(nbr_t_error={phone_validation.phone_get_sanitized_record_number(self.partner_2): 'wrong_format_number'}): + test_record = self.env['mail.test.sms'].browse(self.test_record.id) + messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids) + + self.assertSMSNotification([ + {'partner': self.partner_1, 'state': 'sent'}, + {'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_number_format'}, + {'partner': self.partner_3, 'state': 'sent'}, + ], self._test_body, messages) diff --git a/addons/test_mail_full/tests/test_sms_sms.py b/addons/test_mail_full/tests/test_sms_sms.py new file mode 100644 index 00000000000..46149f559a8 --- /dev/null +++ b/addons/test_mail_full/tests/test_sms_sms.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch +from unittest.mock import DEFAULT + +from odoo import exceptions +from odoo.addons.sms.models.sms_sms import SmsSms as SmsSms +from odoo.addons.sms.tests import common as sms_common +from odoo.addons.test_mail.tests import common as test_mail_common +from odoo.addons.test_mail_full.tests import common as test_mail_full_common + + +class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestSMSPost, cls).setUpClass() + cls._test_body = 'VOID CONTENT' + + cls.sms_all = cls.env['sms.sms'] + for x in range(10): + cls.sms_all |= cls.env['sms.sms'].create({ + 'number': '+324560000%s%s' % (x, x), + 'body': cls._test_body, + }) + + def test_sms_send_batch_size(self): + self.count = 0 + + def _send(sms_self, delete_all=False, raise_exception=False): + self.count += 1 + return DEFAULT + + self.env['ir.config_parameter'].set_param('sms.session.batch.size', '3') + with patch.object(SmsSms, '_send', autospec=True, side_effect=_send) as send_mock: + self.env['sms.sms'].browse(self.sms_all.ids).send() + + self.assertEqual(self.count, 4) + + def test_sms_send_crash_employee(self): + with self.assertRaises(exceptions.AccessError): + self.env['sms.sms'].with_user(self.user_employee).browse(self.sms_all.ids).send() + + def test_sms_send_delete_all(self): + with self.mockSMSGateway(sim_error='jsonrpc_exception'): + self.env['sms.sms'].browse(self.sms_all.ids).send(delete_all=True, raise_exception=False) + self.assertFalse(len(self.sms_all.exists())) + + def test_sms_send_raise(self): + with self.assertRaises(exceptions.AccessError): + with self.mockSMSGateway(sim_error='jsonrpc_exception'): + self.env['sms.sms'].browse(self.sms_all.ids).send(raise_exception=True) + self.assertEqual(set(self.sms_all.mapped('state')), set(['outgoing'])) + + def test_sms_send_raise_catch(self): + with self.mockSMSGateway(sim_error='jsonrpc_exception'): + self.env['sms.sms'].browse(self.sms_all.ids).send(raise_exception=False) + self.assertEqual(set(self.sms_all.mapped('state')), set(['error']))