diff --git a/addons/mass_mailing_sms/__init__.py b/addons/mass_mailing_sms/__init__.py new file mode 100644 index 00000000000..8a8a7a9c11b --- /dev/null +++ b/addons/mass_mailing_sms/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import tests +from . import wizard diff --git a/addons/mass_mailing_sms/__manifest__.py b/addons/mass_mailing_sms/__manifest__.py new file mode 100644 index 00000000000..a31778d1fd6 --- /dev/null +++ b/addons/mass_mailing_sms/__manifest__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'SMS Marketing', + 'summary': 'Design, send and track SMS', + 'description': '', + 'version': '1.0', + 'category': 'Marketing/Email Marketing', + 'depends': [ + 'portal', + 'mass_mailing', + 'sms', + ], + 'data': [ + 'data/utm_data.xml', + 'security/ir.model.access.csv', + 'views/mailing_sms_menus.xml', + 'views/mailing_list_views.xml', + 'views/mailing_contact_views.xml', + 'views/mailing_trace_views.xml', + 'views/mailing_mailing_views.xml', + 'views/link_tracker_views.xml', + 'views/mass_mailing_sms_templates_portal.xml', + 'report/mailing_trace_report_views.xml', + 'wizard/sms_composer_views.xml', + 'wizard/mailing_sms_test_views.xml', + ], + 'demo': [ + ], + 'application': True, +} diff --git a/addons/mass_mailing_sms/controllers/__init__.py b/addons/mass_mailing_sms/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/mass_mailing_sms/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/mass_mailing_sms/controllers/main.py b/addons/mass_mailing_sms/controllers/main.py new file mode 100644 index 00000000000..9f03af5c6d2 --- /dev/null +++ b/addons/mass_mailing_sms/controllers/main.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import werkzeug + +from odoo import http, _ +from odoo.addons.phone_validation.tools import phone_validation +from odoo.http import request + + +class MailingSMSController(http.Controller): + + def _check_trace(self, mailing_id, trace_code): + try: + mailing = request.env['mailing.mailing'].sudo().search([('id', '=', mailing_id)]) + except: + mailing = False + if not mailing: + return {'error': 'mailing_error'} + trace = request.env['mailing.trace'].sudo().search([ + ('trace_type', '=', 'sms'), + ('sms_code', '=', trace_code), + ('mass_mailing_id', '=', mailing.id) + ]) + if not trace: + return {'error': 'trace_error'} + return {'trace': trace} + + @http.route(['/sms//'], type='http', website=True, auth='public') + def blacklist_page(self, mailing_id, trace_code, **post): + check_res = self._check_trace(mailing_id, trace_code) + if not check_res.get('trace'): + return werkzeug.utils.redirect('/web') + return request.render('mass_mailing_sms.blacklist_main', { + 'mailing_id': mailing_id, + 'trace_code': trace_code, + }) + + @http.route(['/sms//unsubscribe/'], type='http', website=True, auth='public') + def blacklist_number(self, mailing_id, trace_code, **post): + check_res = self._check_trace(mailing_id, trace_code) + if not check_res.get('trace'): + return werkzeug.utils.redirect('/web') + country_code = request.session.get('geoip', False) and request.session.geoip.get('country_code', False) if request.session.get('geoip') else None + trace = check_res['trace'] + mailing_list_ids = trace.mass_mailing_id.contact_list_ids + + # parse and validate number + sms_number = post.get('sms_number', '').strip(' ') + sanitize_res = phone_validation.phone_sanitize_numbers([sms_number], country_code, None)[sms_number] + tocheck_number = sanitize_res['sanitized'] or sms_number + + # compute opt-out / blacklist information + lists_optout = request.env['mailing.list'].sudo() + lists_optin = request.env['mailing.list'].sudo() + unsubscribe_error = False + if tocheck_number and trace.sms_number == tocheck_number: + if mailing_list_ids: + subscriptions = request.env['mailing.contact.subscription'].sudo().search([ + ('list_id', 'in', mailing_list_ids.ids), + ('contact_id.phone_sanitized', '=', tocheck_number), + ]) + subscriptions.write({'opt_out': True}) + lists_optout = subscriptions.mapped('list_id') + else: + blacklist_rec = request.env['phone.blacklist'].sudo().add(tocheck_number) + blacklist_rec._message_log( + body=_('Blacklist through mass sms unsubscribe (mailing ID: %s - model: %s)') % + (trace.mass_mailing_id.id, trace.mass_mailing_id.mailing_model_id.display_name)) + lists_optin = request.env['mailing.contact.subscription'].sudo().search([ + ('contact_id.phone_sanitized', '=', tocheck_number), + ('list_id', 'not in', mailing_list_ids.ids), + ('opt_out', '=', False), + ]).mapped('list_id') + elif tocheck_number and trace.sms_number != tocheck_number: + unsubscribe_error = _('Number %s not found' % tocheck_number) + else: + unsubscribe_error = sanitize_res['msg'] + + return request.render('mass_mailing_sms.blacklist_number', { + 'mailing_id': mailing_id, + 'trace_code': trace_code, + 'sms_number': sms_number, + 'lists_optin': lists_optin, + 'lists_optout': lists_optout, + 'unsubscribe_error': unsubscribe_error, + }) + + @http.route('/r//s/', type='http', auth="public") + def sms_short_link_redirect(self, code, sms_sms_id, **post): + # don't assume geoip is set, it is part of the website module + # which mass_mailing doesn't depend on + country_code = request.session.get('geoip', False) and request.session.geoip.get('country_code', False) + if sms_sms_id: + trace_id = request.env['mailing.trace'].sudo().search([('sms_sms_id_int', '=', int(sms_sms_id))]).id + else: + trace_id = False + + request.env['link.tracker.click'].sudo().add_click( + code, + ip=request.httprequest.remote_addr, + country_code=country_code, + mailing_trace_id=trace_id + ) + return werkzeug.utils.redirect(request.env['link.tracker'].get_url_from_code(code), 301) diff --git a/addons/mass_mailing_sms/data/utm_data.xml b/addons/mass_mailing_sms/data/utm_data.xml new file mode 100644 index 00000000000..1b7cef28896 --- /dev/null +++ b/addons/mass_mailing_sms/data/utm_data.xml @@ -0,0 +1,8 @@ + + + + + SMS + + + diff --git a/addons/mass_mailing_sms/models/__init__.py b/addons/mass_mailing_sms/models/__init__.py new file mode 100644 index 00000000000..5d4407a1729 --- /dev/null +++ b/addons/mass_mailing_sms/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import link_tracker +from . import mailing_contact +from . import mailing_list +from . import mailing_mailing +from . import mailing_trace +from . import sms_sms diff --git a/addons/mass_mailing_sms/models/link_tracker.py b/addons/mass_mailing_sms/models/link_tracker.py new file mode 100644 index 00000000000..a8bee7d1546 --- /dev/null +++ b/addons/mass_mailing_sms/models/link_tracker.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re +import werkzeug + +from odoo import models +from odoo.addons.mass_mailing_sms.models.sms_sms import TEXT_URL_REGEX + + +class LinkTracker(models.Model): + _inherit = "link.tracker" + + def _convert_links_text(self, body, vals, blacklist=None): + shortened_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/' + unsubscribe_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/sms/' + for match in re.findall(TEXT_URL_REGEX, body): + original_url = match[0] + # don't shorten already-shortened links or links towards unsubscribe page + if original_url.startswith(shortened_schema) or original_url.startswith(unsubscribe_schema): + continue + # support blacklist items in path, like /u/ + parsed = werkzeug.urls.url_parse(original_url, scheme='http') + if blacklist and any(item in parsed.path for item in blacklist): + continue + + vals['url'] = werkzeug.utils.unescape(original_url) + link = self.create(vals) + shortened_url = link.short_url + if shortened_url: + body = body.replace(original_url, shortened_url) + + return body diff --git a/addons/mass_mailing_sms/models/mailing_contact.py b/addons/mass_mailing_sms/models/mailing_contact.py new file mode 100644 index 00000000000..68050b519d3 --- /dev/null +++ b/addons/mass_mailing_sms/models/mailing_contact.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class MailingContact(models.Model): + _name = 'mailing.contact' + _inherit = ['mailing.contact', 'mail.thread.phone'] + + mobile = fields.Char(string='Mobile') + + def _sms_get_number_fields(self): + return ['mobile'] diff --git a/addons/mass_mailing_sms/models/mailing_list.py b/addons/mass_mailing_sms/models/mailing_list.py new file mode 100644 index 00000000000..56b96df552f --- /dev/null +++ b/addons/mass_mailing_sms/models/mailing_list.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class MailingList(models.Model): + _inherit = 'mailing.list' + + def _compute_contact_nbr(self): + if self.env.context.get('mailing_sms'): + self.env.cr.execute(''' +select list_id, count(*) +from mailing_contact_list_rel r +left join mailing_contact c on (r.contact_id=c.id) +left join phone_blacklist bl on c.phone_sanitized = bl.number and bl.active +where + list_id in %s + AND COALESCE(r.opt_out,FALSE) = FALSE + AND c.phone_sanitized IS NOT NULL + AND bl.id IS NULL +group by list_id''', (tuple(self.ids), )) + data = dict(self.env.cr.fetchall()) + for mailing_list in self: + mailing_list.contact_nbr = data.get(mailing_list.id, 0) + return + return super(MailingList, self)._compute_contact_nbr() + + def action_view_contacts(self): + if self.env.context.get('mailing_sms'): + action = self.env.ref('mass_mailing_sms.mailing_contact_action_sms').read()[0] + action['domain'] = [('list_ids', 'in', self.ids)] + context = dict(self.env.context, search_default_filter_valid_sms_recipient=1, default_list_ids=self.ids) + action['context'] = context + return action + return super(MailingList, self).action_view_contacts() diff --git a/addons/mass_mailing_sms/models/mailing_mailing.py b/addons/mass_mailing_sms/models/mailing_mailing.py new file mode 100644 index 00000000000..96e3eb4c8d1 --- /dev/null +++ b/addons/mass_mailing_sms/models/mailing_mailing.py @@ -0,0 +1,193 @@ +# -*- 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.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class Mailing(models.Model): + _inherit = 'mailing.mailing' + + # mailing options + mailing_type = fields.Selection(selection_add=[('sms', 'SMS')]) + # sms options + body_plaintext = fields.Text('SMS Body') + sms_template_id = fields.Many2one('sms.template', string='SMS Template', ondelete='set null') + sms_has_insufficient_credit = fields.Boolean( + 'Insufficient IAP credits', compute='_compute_sms_has_insufficient_credit', + help='UX Field to propose to buy IAP credits') + # opt_out_link + sms_allow_unsubscribe = fields.Boolean('Include opt-out link', default=True) + + @api.onchange('mailing_type') + def _onchange_mailing_type(self): + if self.mailing_type == 'sms' and (not self.medium_id or self.medium_id == self.env.ref('utm.utm_medium_email')): + self.medium_id = self.env.ref('mass_mailing_sms.utm_medium_sms').id + elif self.mailing_type == 'mail' and (not self.medium_id or self.medium_id == self.env.ref('mass_mailing_sms.utm_medium_sms')): + self.medium_id = self.env.ref('utm.utm_medium_email').id + + @api.onchange('sms_template_id', 'mailing_type') + def _onchange_sms_template_id(self): + if self.mailing_type == 'sms' and self.sms_template_id: + self.body_plaintext = self.sms_template_id.body + + @api.depends('mailing_trace_ids.failure_type') + def _compute_sms_has_insufficient_credit(self): + mailing_ids = self.env['mailing.trace'].sudo().search([ + ('mass_mailing_id', 'in', self.ids), + ('trace_type', '=', 'sms'), + ('failure_type', '=', 'sms_credit') + ]).mapped('mass_mailing_id') + for mailing in self: + mailing.sms_has_insufficient_credit = mailing in mailing_ids + + # -------------------------------------------------- + # CRUD + # -------------------------------------------------- + + @api.model + def create(self, values): + if values.get('mailing_type') == 'sms': + if not values.get('medium_id'): + values['medium_id'] = self.env.ref('mass_mailing_sms.utm_medium_sms').id + if values.get('sms_template_id') and not values.get('body_plaintext'): + values['body_plaintext'] = self.env['sms.template'].browse(values['sms_template_id']).body + return super(Mailing, self).create(values) + + # -------------------------------------------------- + # BUSINESS / VIEWS ACTIONS + # -------------------------------------------------- + + def action_put_in_queue_sms(self): + return self.action_put_in_queue() + + def action_test(self): + if self.mailing_type == 'sms': + ctx = dict(self.env.context, default_mailing_id=self.id) + return { + 'name': _('Test Mailing'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mailing.sms.test', + 'target': 'new', + 'context': ctx, + } + return super(Mailing, self).action_test() + + def _action_view_traces_filtered(self, view_filter): + action = super(Mailing, self)._action_view_traces_filtered(view_filter) + if self.mailing_type == 'sms': + action['views'] = [(self.env.ref('mass_mailing_sms.mailing_trace_view_tree_sms').id, 'tree'), + (self.env.ref('mass_mailing_sms.mailing_trace_view_form_sms').id, 'form')] + return action + + def action_buy_sms_credits(self): + url = self.env['iap.account'].get_credits_url(service_name='sms') + return { + 'type': 'ir.actions.act_url', + 'url': url, + } + + # -------------------------------------------------- + # SMS SEND + # -------------------------------------------------- + + def _get_opt_out_list_sms(self): + """Returns a set of emails opted-out in target model""" + self.ensure_one() + opt_out = [] + target = self.env[self.mailing_model_real] + if self.mailing_model_real == "mail.mass_mailing.contact": + # if user is opt_out on One list but not on another + # or if two user with same email address, one opted in and the other one opted out, send the mail anyway + # TODO DBE Fixme : Optimise the following to get real opt_out and opt_in + subscriptions = self.env['mailing.contact.subscription'].sudo().search( + [('list_id', 'in', self.contact_list_ids.ids)]) + opt_out_contacts = subscriptions.filtered(lambda sub: sub.opt_out).mapped('contact_id') + opt_in_contacts = subscriptions.filtered(lambda sub: not sub.opt_out).mapped('contact_id') + opt_out = list(set(c.id for c in opt_out_contacts if c not in opt_in_contacts)) + + _logger.info("Mass SMS %s targets %s: optout: %s contacts", self, target._name, len(opt_out)) + else: + _logger.info("Mass SMS %s targets %s: no opt out list available", self, target._name) + return opt_out + + def _get_seen_list_sms(self): + """Returns a set of emails already targeted by current mailing/campaign (no duplicates)""" + self.ensure_one() + target = self.env[self.mailing_model_real] + + if issubclass(type(target), self.pool['mail.thread.phone']): + phone_fields = ['phone_sanitized'] + elif issubclass(type(target), self.pool['mail.thread']): + phone_fields = target._sms_get_number_fields() + else: + phone_fields = [] + if 'mobile' in target._fields: + phone_fields.append('mobile') + if 'phone' in target._fields: + phone_fields.append('phone') + if not phone_fields: + raise UserError(_("Unsupported %s for mass SMS") % self.mailing_model_id.name) + + query = """ + SELECT %(select_query)s + FROM mailing_trace trace + JOIN %(target_table)s target ON (trace.res_id = target.id) + WHERE (%(where_query)s) + AND trace.mass_mailing_id = %%(mailing_id)s + AND trace.model = %%(target_model)s + """ + query = query % { + 'select_query': 'target.id, ' + ', '.join('target.%s' % fname for fname in phone_fields), + 'where_query': ' OR '.join('target.%s IS NOT NULL' % fname for fname in phone_fields), + 'target_table': target._table + } + params = {'mailing_id': self.id, 'target_model': self.mailing_model_real} + self._cr.execute(query, params) + query_res = self._cr.fetchall() + seen_list = set(number for item in query_res for number in item[1:] if number) + seen_ids = set(item[0] for item in query_res) + _logger.info("Mass SMS %s targets %s: already reached %s SMS", self, target._name, len(seen_list)) + return list(seen_ids), list(seen_list) + + def _send_sms_get_composer_values(self, res_ids): + return { + # content + 'body': self.body_plaintext, + 'template_id': self.sms_template_id.id, + 'res_model': self.mailing_model_real, + 'res_ids': repr(res_ids), + # options + 'composition_mode': 'mass', + 'mailing_id': self.id, + 'mass_keep_log': False, + 'mass_sms_allow_unsubscribe': self.sms_allow_unsubscribe, + } + + def action_send_mail(self, res_ids=None): + mass_sms = self.filtered(lambda m: m.mailing_type == 'sms') + if mass_sms: + mass_sms.action_send_sms(res_ids=res_ids) + return super(Mailing, self - mass_sms).action_send_mail(res_ids=res_ids) + + def action_send_sms(self, res_ids=None): + for mailing in self: + if not res_ids: + res_ids = mailing._get_remaining_recipients() + if not res_ids: + raise UserError(_('There is no recipients selected.')) + + composer = self.env['sms.composer'].create(mailing._send_sms_get_composer_values(res_ids)) + # extra_context = self._get_mass_mailing_context() + + # auto-commit except in testing mode + # auto_commit = not getattr(threading.currentThread(), 'testing', False) + # composer.send_mail(auto_commit=auto_commit) + composer._action_send_sms() + mailing.write({'state': 'done', 'sent_date': fields.Datetime.now()}) + return True diff --git a/addons/mass_mailing_sms/models/mailing_trace.py b/addons/mass_mailing_sms/models/mailing_trace.py new file mode 100644 index 00000000000..56bb84b0eff --- /dev/null +++ b/addons/mass_mailing_sms/models/mailing_trace.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import random +import string + +from odoo import api, fields, models +from odoo.osv import expression + + +class MailingTrace(models.Model): + """ Improve statistics model to add SMS support. Main attributes of + statistics model are used, only some specific data is required. """ + _inherit = 'mailing.trace' + CODE_SIZE = 3 + + trace_type = fields.Selection(selection_add=[('sms', 'SMS')]) + sms_sms_id = fields.Many2one('sms.sms', string='SMS', index=True, ondelete='set null') + sms_sms_id_int = fields.Integer( + string='SMS ID (tech)', + help='ID of the related sms.sms. This field is an integer field because ' + 'the related sms.sms can be deleted separately from its statistics. ' + 'However the ID is needed for several action and controllers.', + index=True, + ) + sms_number = fields.Char('Number') + sms_code = fields.Char('Code') + 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'), + # mass mode specific codes + ('sms_blacklist', 'Blacklisted'), + ('sms_duplicate', 'Duplicate'), + ]) + + @api.model_create_multi + def create(self, values_list): + for values in values_list: + if 'sms_sms_id' in values: + values['sms_sms_id_int'] = values['sms_sms_id'] + if values.get('trace_type') == 'sms' and not values.get('sms_code'): + values['sms_code'] = self._get_random_code() + return super(MailingTrace, self).create(values_list) + + def _get_random_code(self): + """ Generate a random code for trace. Uniqueness is not really necessary + as it serves as obfuscation when unsubscribing. A valid trio + code / mailing_id / number will be requested. """ + return ''.join(random.choice(string.ascii_letters + string.digits) for dummy in range(self.CODE_SIZE)) + + def _get_records_from_sms(self, sms_sms_ids=None, additional_domain=None): + if not self.ids and sms_sms_ids: + domain = [('sms_sms_id_int', 'in', sms_sms_ids)] + else: + domain = [('id', 'in', self.ids)] + if additional_domain: + domain = expression.AND([domain, additional_domain]) + return self.search(domain) + + def set_sms_sent(self, sms_sms_ids=None): + statistics = self._get_records_from_sms(sms_sms_ids, [('sent', '=', False)]) + statistics.write({'sent': fields.Datetime.now()}) + return statistics + + def set_sms_clicked(self, sms_sms_ids=None): + statistics = self._get_records_from_sms(sms_sms_ids, [('clicked', '=', False)]) + statistics.write({'clicked': fields.Datetime.now()}) + return statistics + + def set_sms_ignored(self, sms_sms_ids=None): + statistics = self._get_records_from_sms(sms_sms_ids, [('ignored', '=', False)]) + statistics.write({'ignored': fields.Datetime.now()}) + return statistics + + def set_sms_exception(self, sms_sms_ids=None): + statistics = self._get_records_from_sms(sms_sms_ids, [('exception', '=', False)]) + statistics.write({'exception': fields.Datetime.now()}) + return statistics diff --git a/addons/mass_mailing_sms/models/sms_sms.py b/addons/mass_mailing_sms/models/sms_sms.py new file mode 100644 index 00000000000..2167faa46ef --- /dev/null +++ b/addons/mass_mailing_sms/models/sms_sms.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from odoo import fields, models + +TEXT_URL_REGEX = r'(https?:\/\/(www\.)?[a-zA-Z0-9@:%._\+~#=/-]{1,64})' + + +class SmsSms(models.Model): + _inherit = ['sms.sms'] + + mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing') + mailing_trace_ids = fields.One2many('mailing.trace', 'sms_sms_id', string='Statistics') + + def _update_body_short_links(self): + """ Override to tweak shortened URLs by adding statistics ids, allowing to + find customer back once clicked. """ + shortened_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/' + res = dict.fromkeys(self.ids, False) + for sms in self: + if not sms.mailing_id or not sms.body: + res[sms.id] = sms.body + continue + + body = sms.body + for match in re.findall(TEXT_URL_REGEX, body): + url = match[0] + if url.startswith(shortened_schema): + body = body.replace(url, url + '/s/%s' % sms.id) + res[sms.id] = body + return res + + def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, delete_all=False): + all_sms_ids = [item['res_id'] for item in iap_results] + if any(sms.mailing_id for sms in self.env['sms.sms'].sudo().browse(all_sms_ids)): + for state in self.IAP_TO_SMS_STATE.keys(): + sms_ids = [item['res_id'] for item in iap_results if item['state'] == state] + traces = self.env['mailing.trace'].sudo().search([ + ('sms_sms_id_int', 'in', sms_ids) + ]) + if traces and state == 'success': + traces.write({'sent': fields.Datetime.now(), 'exception': False}) + elif traces: + traces.write({'exception': fields.Datetime.now(), 'failure_type': self.IAP_TO_SMS_STATE[state]}) + return super(SmsSms, self)._postprocess_iap_sent_sms(iap_results, failure_reason=failure_reason, delete_all=delete_all) diff --git a/addons/mass_mailing_sms/report/mailing_trace_report_views.xml b/addons/mass_mailing_sms/report/mailing_trace_report_views.xml new file mode 100644 index 00000000000..2adf5953faf --- /dev/null +++ b/addons/mass_mailing_sms/report/mailing_trace_report_views.xml @@ -0,0 +1,71 @@ + + + + mailing.trace.report.view.pivot + mailing.trace.report + + + + + + + + + + + + + + + mailing.trace.report.view.graph + mailing.trace.report + + + + + + + + + + mailing.trace.report.view.search + mailing.trace.report + + + + + + + + + + + + + + + + + + + Mass SMS Analysis + mailing.trace.report + graph,pivot + [('mailing_type', '=', 'sms')] + +

+ Mass SMS Statistics allows you to check different mailing related information like number of sent SMS or bounced SMS. + You can sort out your analysis by different groups to get accurate grained analysis. +

+
+
+ + + + + +
diff --git a/addons/mass_mailing_sms/security/ir.model.access.csv b/addons/mass_mailing_sms/security/ir.model.access.csv new file mode 100644 index 00000000000..4a08a4aa60d --- /dev/null +++ b/addons/mass_mailing_sms/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/addons/mass_mailing_sms/static/description/icon.png b/addons/mass_mailing_sms/static/description/icon.png new file mode 100644 index 00000000000..7dbb33addee Binary files /dev/null and b/addons/mass_mailing_sms/static/description/icon.png differ diff --git a/addons/mass_mailing_sms/static/description/icon.svg b/addons/mass_mailing_sms/static/description/icon.svg new file mode 100644 index 00000000000..89055592c82 --- /dev/null +++ b/addons/mass_mailing_sms/static/description/icon.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/mass_mailing_sms/tests/__init__.py b/addons/mass_mailing_sms/tests/__init__.py new file mode 100644 index 00000000000..511e649c9e2 --- /dev/null +++ b/addons/mass_mailing_sms/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common diff --git a/addons/mass_mailing_sms/tests/common.py b/addons/mass_mailing_sms/tests/common.py new file mode 100644 index 00000000000..bffc117ac22 --- /dev/null +++ b/addons/mass_mailing_sms/tests/common.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +from contextlib import contextmanager +from unittest.mock import patch + +from odoo import exceptions, tools +from odoo.addons.phone_validation.tools import phone_validation +from odoo.addons.sms.tests import common as sms_common +from odoo.tests import common +from odoo.addons.sms.models.sms_api import SmsApi + + +class MockSMS(sms_common.MockSMS): + + def assertSMSOutgoingStatistics(self, partners, numbers, records, mailing): + found_sms = self.env['sms.sms'].sudo().search([ + '|', ('partner_id', 'in', partners.ids), ('number', 'in', numbers), + ('state', '=', 'outgoing') + ]) + self.assertEqual(found_sms.filtered(lambda s: s.partner_id).mapped('partner_id'), partners) + self.assertEqual(set(found_sms.filtered(lambda s: not s.partner_id).mapped('number')), set(numbers)) + + found_traces = self.env['mailing.trace'].sudo().search([ + ('sms_sms_id_int', 'in', found_sms.ids), + ]) + self.assertEqual(len(found_sms), len(found_traces)) + self.assertTrue(all(s.state == 'outgoing' for s in found_traces)) + self.assertTrue(all(s.res_model == records._name for s in found_traces)) + self.assertEqual(set(found_traces.mapped('res_id')), set(records.ids)) + self.assertTrue(all(s.mass_mailing_id == mailing for s in found_traces)) + + def assertSMSStatistics(self, recipients_info, mailing, records, 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': outgoing / sent / ignored / exception / opened (sent by default), + 'record: linked record, + 'failure_type': optional: sms_number_missing / sms_number_format / sms_credit / sms_server + }, { ... }] + """ + traces = self.env['mailing.trace'].search([ + ('mass_mailing_id', 'in', mailing.ids), + ('res_id', 'in', records.ids) + ]) + + self.assertTrue(all(s.model == records._name for s in traces)) + # self.assertTrue(all(s.utm_campaign_id == mailing.campaign_id for s in traces)) + self.assertEqual(set(s.res_id for s in traces), set(records.ids)) + + 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', 'outgoing') + content = recipient_info.get('content', None) + if number is None and partner: + number = phone_validation.phone_get_sanitized_record_number(partner) + + notif = traces.filtered(lambda s: s.sms_number == number and s.state == state) + self.assertTrue(notif, 'SMS: not found notification for number %s, (state: %s)' % (number, state)) + + if check_sms: + if state == 'sent': + self.assertSMSSent([number], content) + elif state == 'outgoing': + self.assertSMSOutgoing(partner, number, content) + elif state == 'exception': + self.assertSMSFailed(partner, number, recipient_info.get('failure_type'), content) + elif state == 'ignored': + self.assertSMSCanceled(partner, number, recipient_info.get('failure_type', False), content) + else: + raise NotImplementedError() diff --git a/addons/mass_mailing_sms/views/link_tracker_views.xml b/addons/mass_mailing_sms/views/link_tracker_views.xml new file mode 100644 index 00000000000..30ab3459e02 --- /dev/null +++ b/addons/mass_mailing_sms/views/link_tracker_views.xml @@ -0,0 +1,8 @@ + + + + diff --git a/addons/mass_mailing_sms/views/mailing_contact_views.xml b/addons/mass_mailing_sms/views/mailing_contact_views.xml new file mode 100644 index 00000000000..374191ec91f --- /dev/null +++ b/addons/mass_mailing_sms/views/mailing_contact_views.xml @@ -0,0 +1,96 @@ + + + + mailing.contact.view.search.inherit.sms + mailing.contact + + + + + + + + + + + + + + + + mailing.contact.view.tree.sms + mailing.contact + 20 + + + + + + + + + + + + + + mailing.contact.view.form.inherit.sms + mailing.contact + + + + context.get('mailing_sms') == True + + + context.get('mailing_sms') == True + + + + + + + + mailing.contact.view.kanban.inherit.sms + mailing.contact + + + + + + + + + + + + + Contacts + mailing.contact + tree,form + {'mailing_sms': True, 'search_default_filter_not_phone_bl': 1, } + + +

+ Create a new contact +

+
+
+ + + + + + +
diff --git a/addons/mass_mailing_sms/views/mailing_list_views.xml b/addons/mass_mailing_sms/views/mailing_list_views.xml new file mode 100644 index 00000000000..d1b88669078 --- /dev/null +++ b/addons/mass_mailing_sms/views/mailing_list_views.xml @@ -0,0 +1,22 @@ + + + + Contact Lists + mailing.list + kanban,tree,form + {'mailing_sms': True} + +

+ Create a new contacts list +

+ You don't need to import your contacts lists, you can easily + send SMS to any contact saved in other Odoo apps. +

+
+
+ + + + + +
diff --git a/addons/mass_mailing_sms/views/mailing_mailing_views.xml b/addons/mass_mailing_sms/views/mailing_mailing_views.xml new file mode 100644 index 00000000000..d4517cf0b1f --- /dev/null +++ b/addons/mass_mailing_sms/views/mailing_mailing_views.xml @@ -0,0 +1,183 @@ + + + + mailing.mailing.view.form.inherit.sms + mailing.mailing + + + + + {'invisible': ['|', ('state', 'in', ('in_queue', 'done')), ('mailing_type', '!=', 'mail')]} + + + +

+ +
+ + {'invisible': [('mailing_type', '!=', 'mail')]} + + + SMS Text Message have been ignored and will not be sent. + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + SMS Text Message are in queue and will be sent soon. + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + SMS Text Message could not be sent. + + + + {'invisible': ['|', '&', ('sent', '=', 0), ('state', 'in', ('draft', 'test')), ('mailing_type', '!=', 'mail')]} + + + + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + + + + + + + + + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + +
+
+ + + mailing.mailing.view.kanban.inherit.sms + mailing.mailing + + + + + + + + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + {'invisible': [('mailing_type', '!=', 'mail')]} + + + + + + mailing.mailing.view.tree.sms + mailing.mailing + 20 + + + + + + + + + + + + + + Mass SMS + mailing.mailing + kanban,tree,form,graph + [('mailing_type', '=', 'sms')] + { + 'search_default_assigned_to_me': 1, + 'default_user_id': uid, + 'default_mailing_type': 'sms', + } + +

+ Create a new mass SMS +

+ You can easily send SMS to any contact saved in other Odoo apps. +

+
+
+ + 1 + kanban + + + + + 2 + tree + + + + + + + + + +
diff --git a/addons/mass_mailing_sms/views/mailing_sms_menus.xml b/addons/mass_mailing_sms/views/mailing_sms_menus.xml new file mode 100644 index 00000000000..f7cc0f05edb --- /dev/null +++ b/addons/mass_mailing_sms/views/mailing_sms_menus.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons/mass_mailing_sms/views/mailing_trace_views.xml b/addons/mass_mailing_sms/views/mailing_trace_views.xml new file mode 100644 index 00000000000..d109c86b8b4 --- /dev/null +++ b/addons/mass_mailing_sms/views/mailing_trace_views.xml @@ -0,0 +1,121 @@ + + + + mailing.trace.view.search.inherit.sms + mailing.trace + + + + + + + + + + + + mailing.trace.view.tree.inherit.sms + mailing.trace + + + + + + + + + + + + + mailing.trace.view.tree.sms + mailing.trace + 20 + + + + + + + + + + + + + + + + + mailing.trace.view.form.inherit.sms + mailing.trace + + + + {'invisible': [('trace_type', '!=', 'mail')]} + + + This sms + + + {'invisible': [('trace_type', '!=', 'mail')]} + + + {'invisible': [('trace_type', '!=', 'mail')]} + + + {'invisible': [('trace_type', '!=', 'mail')]} + + + {'invisible': [('trace_type', '=', 'sms')]} + + + {'invisible': [('trace_type', '=', 'sms')]} + + + + + + + + + + + mailing.trace.view.form.sms + mailing.trace + 20 + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/addons/mass_mailing_sms/views/mass_mailing_sms_templates_portal.xml b/addons/mass_mailing_sms/views/mass_mailing_sms_templates_portal.xml new file mode 100644 index 00000000000..bd1f927eb9d --- /dev/null +++ b/addons/mass_mailing_sms/views/mass_mailing_sms_templates_portal.xml @@ -0,0 +1,60 @@ + + + + + + diff --git a/addons/mass_mailing_sms/wizard/__init__.py b/addons/mass_mailing_sms/wizard/__init__.py new file mode 100644 index 00000000000..eecb3976fa5 --- /dev/null +++ b/addons/mass_mailing_sms/wizard/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import mailing_sms_test +from . import sms_composer diff --git a/addons/mass_mailing_sms/wizard/mailing_sms_test.py b/addons/mass_mailing_sms/wizard/mailing_sms_test.py new file mode 100644 index 00000000000..72552715b59 --- /dev/null +++ b/addons/mass_mailing_sms/wizard/mailing_sms_test.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, exceptions, fields, models, _ +from odoo.addons.phone_validation.tools import phone_validation + + +class MassSMSTest(models.TransientModel): + _name = 'mailing.sms.test' + _description = 'Test SMS Mailing' + + def _default_numbers(self): + return self.env.user.partner_id.phone_sanitized or "" + + numbers = fields.Char(string='Number', required=True, + default=_default_numbers, help='Comma-separated list of phone numbers') + sanitized_numbers = fields.Char(compute='_compute_sanitized_numbers') + mailing_id = fields.Many2one('mailing.mailing', string='Mailing', required=True, ondelete='cascade') + + @api.depends('numbers') + def _compute_sanitized_numbers(self): + if self.numbers: + numbers = [number.strip() for number in self.numbers.split(',')] + sanitize_res = phone_validation.phone_sanitize_numbers_w_record(numbers, self.env.user) + sanitized_numbers = [info['sanitized'] for info in sanitize_res.values() if info['sanitized']] + invalid_numbers = [number for number, info in sanitize_res.items() if info['code']] + if invalid_numbers: + raise exceptions.UserError(_('Following numbers are not correctly encoded: %s') % repr(invalid_numbers)) + self.sanitized_numbers = ','.join(sanitized_numbers) + else: + self.sanitized_numbers = False + + def action_send_sms(self): + self.ensure_one() + self.env['sms.api']._send_sms_batch([{ + 'res_id': 0, + 'number': number, + 'content': self.mailing_id.body_plaintext, + } for number in self.sanitized_numbers.split(',')]) + return True diff --git a/addons/mass_mailing_sms/wizard/mailing_sms_test_views.xml b/addons/mass_mailing_sms/wizard/mailing_sms_test_views.xml new file mode 100644 index 00000000000..97ed90df4e5 --- /dev/null +++ b/addons/mass_mailing_sms/wizard/mailing_sms_test_views.xml @@ -0,0 +1,30 @@ + + + + mailing.sms.test.view.form + mailing.sms.test + +
+

+ Send a sample SMS for testing purpose to the numbers below. +

+ + + + + +
+
+
+
+
+ + + Test SMS Mailing + mailing.sms.test + form + new + +
diff --git a/addons/mass_mailing_sms/wizard/sms_composer.py b/addons/mass_mailing_sms/wizard/sms_composer.py new file mode 100644 index 00000000000..584b15c66eb --- /dev/null +++ b/addons/mass_mailing_sms/wizard/sms_composer.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import werkzeug.urls + +from odoo import fields, models + + +class SMSComposer(models.TransientModel): + _inherit = 'sms.composer' + + # mass mode with mass sms + mass_sms_allow_unsubscribe = fields.Boolean('Include opt-out link', default=True) + mailing_id = fields.Many2one('mailing.mailing', string='Mailing') + utm_campaign_id = fields.Many2one('utm.campaign', string='Campaign') + + # ------------------------------------------------------------ + # Mass mode specific + # ------------------------------------------------------------ + + def _get_unsubscribe_url(self, res_id, trace_code, number): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + return werkzeug.urls.url_join( + base_url, + '/sms/%s/%s' % (self.mailing_id.id, trace_code) + ) + + def _prepare_mass_sms_trace_values(self, record, sms_values): + trace_code = self.env['mailing.trace']._get_random_code() + trace_values = { + 'model': self.res_model, + 'res_id': record.id, + 'trace_type': 'sms', + 'mass_mailing_id': self.mailing_id.id, + 'sms_number': sms_values['number'], + 'sms_code': trace_code, + } + if sms_values['state'] == 'error': + if sms_values['error_code'] == 'sms_number_format': + trace_values['sent'] = fields.Datetime.now() + trace_values['bounced'] = fields.Datetime.now() + else: + trace_values['exception'] = fields.Datetime.now() + elif sms_values['state'] == 'canceled': + trace_values['ignored'] = fields.Datetime.now() + else: + if self.mass_sms_allow_unsubscribe: + sms_values['body'] = '%s %s' % ((sms_values['body'] or ''), self._get_unsubscribe_url(record.id, trace_code, sms_values['number'])) + return trace_values + + def _get_blacklist_record_ids(self, records, recipients_info): + """ Consider opt-outed contact as being blacklisted for that specific + mailing. """ + res = super(SMSComposer, self)._get_blacklist_record_ids(records, recipients_info) + if self.mailing_id: + optout_res_ids = self.mailing_id._get_opt_out_list_sms() + res += optout_res_ids + return res + + def _get_done_record_ids(self, records, recipients_info): + """ A/B testing could lead to records having been already mailed. """ + res = super(SMSComposer, self)._get_done_record_ids(records, recipients_info) + if self.mailing_id: + seen_ids, seen_list = self.mailing_id._get_seen_list_sms() + res += seen_ids + return res + + def _prepare_body_values(self, records): + all_bodies = super(SMSComposer, self)._prepare_body_values(records) + if self.mailing_id: + tracker_values = self.mailing_id._get_link_tracker_values() + for sms_id, body in all_bodies.items(): + body = self.env['link.tracker'].sudo()._convert_links_text(body, tracker_values) + all_bodies[sms_id] = body + return all_bodies + + def _prepare_mass_sms_values(self, records=None): + result = super(SMSComposer, self)._prepare_mass_sms_values(records=records) + if self.composition_mode == 'mass' and self.mailing_id: + records = records if records is not None else self._get_records() + + # for record_id, sms_values in result.items(): + for record in records: + sms_values = result[record.id] + + trace_values = self._prepare_mass_sms_trace_values(record, sms_values) + sms_values.update({ + 'mailing_id': self.mailing_id.id, + 'mailing_trace_ids': [(0, 0, trace_values)], + }) + return result + + def _action_send_sms_mass(self, records=None): + sms_all = super(SMSComposer, self)._action_send_sms_mass() + if self.mailing_id: + updated_bodies = sms_all._update_body_short_links() + for sms in sms_all: + sms.body = updated_bodies[sms.id] + return sms_all diff --git a/addons/mass_mailing_sms/wizard/sms_composer_views.xml b/addons/mass_mailing_sms/wizard/sms_composer_views.xml new file mode 100644 index 00000000000..139338c78d0 --- /dev/null +++ b/addons/mass_mailing_sms/wizard/sms_composer_views.xml @@ -0,0 +1,16 @@ + + + + sms.composer.views.inherit.sms + sms.composer + + + + + + + + + + diff --git a/addons/test_mail_full/__manifest__.py b/addons/test_mail_full/__manifest__.py index 3f6c16e5639..45ac06a9258 100644 --- a/addons/test_mail_full/__manifest__.py +++ b/addons/test_mail_full/__manifest__.py @@ -15,7 +15,8 @@ real applications. """, 'mail', 'mail_bot', # 'snailmail', - # 'mass_mailing', + 'mass_mailing', + 'mass_mailing_sms', 'phone_validation', 'sms', ], diff --git a/addons/test_mail_full/tests/__init__.py b/addons/test_mail_full/tests/__init__.py index 36523fca998..ce173406381 100644 --- a/addons/test_mail_full/tests/__init__.py +++ b/addons/test_mail_full/tests/__init__.py @@ -2,6 +2,7 @@ from . import common from . import test_phone_blacklist +from . import test_mass_sms from . import test_sms_composer from . import test_sms_management from . import test_sms_performance diff --git a/addons/test_mail_full/tests/common.py b/addons/test_mail_full/tests/common.py index 0bd34a96277..5a7c3d88e07 100644 --- a/addons/test_mail_full/tests/common.py +++ b/addons/test_mail_full/tests/common.py @@ -2,6 +2,7 @@ from odoo.addons.phone_validation.tools import phone_validation from odoo.addons.test_mail.tests import common as test_mail_common +from odoo.addons.test_mail.tests.common import mail_new_test_user class BaseFunctionalTest(test_mail_common.BaseFunctionalTest): @@ -62,3 +63,14 @@ class TestRecipients(test_mail_common.TestRecipients): 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) ] + + +class MassSMSBaseFunctionalTest(BaseFunctionalTest): + + @classmethod + def setUpClass(cls): + super(MassSMSBaseFunctionalTest, cls).setUpClass() + cls.user_marketing = mail_new_test_user( + cls.env, login='marketing', + groups='base.group_user,mass_mailing.group_mass_mailing_user', + name='Martial Marketing', signature='--\nMartial') diff --git a/addons/test_mail_full/tests/test_mass_sms.py b/addons/test_mail_full/tests/test_mass_sms.py new file mode 100644 index 00000000000..2dc589ec9d0 --- /dev/null +++ b/addons/test_mail_full/tests/test_mass_sms.py @@ -0,0 +1,152 @@ +# -*- 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.mass_mailing_sms.tests import common as mass_mailing_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.tools import mute_logger +from odoo.tests import tagged + + +@tagged('mass_mailing') +class TestMassSMS(test_mail_full_common.MassSMSBaseFunctionalTest, mass_mailing_sms_common.MockSMS): + + @classmethod + def setUpClass(cls): + super(TestMassSMS, cls).setUpClass() + cls._test_body = 'Mass SMS in your face' + + records = cls.env['mail.test.sms'] + partners = cls.env['res.partner'] + country_be_id = cls.env.ref('base.be').id, + country_us_id = cls.env.ref('base.us').id, + + for x in range(10): + partners += cls.env['res.partner'].with_context(**cls._test_context).create({ + 'name': 'Partner_%s' % (x), + 'email': '_test_partner_%s@example.com' % (x), + 'country_id': country_be_id, + 'mobile': '045600%s%s99' % (x, x) + }) + records += cls.env['mail.test.sms'].with_context(**cls._test_context).create({ + 'name': 'MassSMSTest_%s' % (x), + 'customer_id': partners[x].id, + 'phone_nbr': '045600%s%s44' % (x, x) + }) + cls.records = cls._reset_mail_context(records) + cls.records_numbers = [phone_validation.phone_format(r.phone_nbr, 'BE', '32', force_format='E164') for r in cls.records] + cls.partners = partners + + cls.sms_template = cls.env['sms.template'].create({ + 'name': 'Test Template', + 'model_id': cls.env['ir.model']._get('mail.test.sms').id, + 'body': 'Dear ${object.display_name} this is a mass SMS.', + }) + + cls.partner_numbers = [ + phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164') + for partner in partners + ] + + cls.mailing = cls.env['mailing.mailing'].with_user(cls.user_marketing).create({ + 'name': 'Xmas Spam', + 'subject': 'Xmas Spam', + 'mailing_model_id': cls.env['ir.model']._get('mail.test.sms').id, + 'mailing_type': 'sms', + 'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]), + 'sms_template_id': cls.sms_template.id, + 'sms_allow_unsubscribe': False, + }) + + def test_mass_sms_internals(self): + with self.sudo('marketing'): + mailing = self.env['mailing.mailing'].create({ + 'name': 'Xmas Spam', + 'subject': 'Xmas Spam', + 'mailing_model_id': self.env['ir.model']._get('mail.test.sms').id, + 'mailing_type': 'sms', + 'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]), + 'sms_template_id': self.sms_template.id, + 'sms_allow_unsubscribe': False, + }) + + self.assertEqual(mailing.mailing_model_real, 'mail.test.sms') + self.assertEqual(mailing.medium_id, self.env.ref('mass_mailing_sms.utm_medium_sms')) + self.assertEqual(mailing.body_plaintext, self.sms_template.body) + + remaining_res_ids = mailing._get_remaining_recipients() + self.assertEqual(set(remaining_res_ids), set(self.records.ids)) + + with self.mockSMSGateway(): + mailing.action_send_sms() + + self.assertSMSStatistics( + [{'partner': record.customer_id, 'number': self.records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records)], + mailing, self.records, check_sms=True + ) + + def test_mass_sms_internals_errors(self): + # same customer, specific different number on record -> should be valid + new_record_1 = self.env['mail.test.sms'].create({ + 'name': 'MassSMSTest_nr1', + 'customer_id': self.partners[0].id, + 'phone_nbr': '0456999999', + }) + # new customer, number already on record -> should be ignored + country_be_id = self.env.ref('base.be').id, + nr2_partner = self.env['res.partner'].create({ + 'name': 'Partner_nr2', + 'country_id': country_be_id, + 'mobile': '0456449999', + }) + new_record_2 = self.env['mail.test.sms'].create({ + 'name': 'MassSMSTest_nr2', + 'customer_id': nr2_partner.id, + 'phone_nbr': self.records[0].phone_nbr, + }) + records_numbers = self.records_numbers + ['+32456999999'] + + with self.sudo('marketing'): + with self.mockSMSGateway(): + self.mailing.action_send_sms() + + self.assertSMSStatistics( + [{'partner': record.customer_id, 'number': records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records | new_record_1)], + self.mailing, self.records | new_record_1, check_sms=True + ) + self.assertSMSStatistics( + [{'partner': new_record_2.customer_id, 'number': self.records_numbers[0], 'content': 'Dear %s this is a mass SMS.' % new_record_2.display_name, 'state': 'ignored', 'failure_type': 'sms_duplicate'}], + self.mailing, new_record_2, check_sms=True + ) + + def test_mass_sms_internals_done_ids(self): + with self.sudo('marketing'): + with self.mockSMSGateway(): + self.mailing.action_send_sms(res_ids=self.records[:5].ids) + + traces = self.env['mailing.trace'].search([('mass_mailing_id', 'in', self.mailing.ids)]) + self.assertEqual(len(traces), 5) + # new traces generated + self.assertSMSStatistics( + [{'partner': record.customer_id, 'number': self.records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records[:5])], + self.mailing, self.records[:5], check_sms=True + ) + + with self.sudo('marketing'): + with self.mockSMSGateway(): + self.mailing.action_send_sms(res_ids=self.records.ids) + + # delete old traces (for testing purpose: ease check by deleting old ones) + traces.unlink() + # new failed traces generated for duplicates + self.assertSMSStatistics( + [{'partner': record.customer_id, 'number': self.records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name, 'state': 'ignored', 'failure_type': 'sms_duplicate'} for i, record in enumerate(self.records[:5])], + self.mailing, self.records[:5], check_sms=True + ) + # new traces generated + self.assertSMSStatistics( + [{'partner': record.customer_id, 'number': self.records_numbers[i+5], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records[5:])], + self.mailing, self.records[5:], check_sms=True + ) diff --git a/addons/test_mail_full/tests/test_sms_performance.py b/addons/test_mail_full/tests/test_sms_performance.py index 748d5184fd6..87ff9c7cdb5 100644 --- a/addons/test_mail_full/tests/test_sms_performance.py +++ b/addons/test_mail_full/tests/test_sms_performance.py @@ -142,8 +142,8 @@ class TestSMSMassPerformance(BaseMailPerformance, sms_common.MockSMS): 'mass_keep_log': False, }) - with self.mockSMSGateway(), self.assertQueryCount(employee=107): - composer.action_send_sms() + with self.mockSMSGateway(), self.assertQueryCount(employee=108): + composer.action_send_sms() @mute_logger('odoo.addons.sms.models.sms_sms') @users('employee') diff --git a/addons/test_mail_full/tests/test_sms_sms.py b/addons/test_mail_full/tests/test_sms_sms.py index 426e3872901..4bafe20ec89 100644 --- a/addons/test_mail_full/tests/test_sms_sms.py +++ b/addons/test_mail_full/tests/test_sms_sms.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import werkzeug + from unittest.mock import patch from unittest.mock import DEFAULT @@ -8,9 +10,52 @@ 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_full.tests import common as test_mail_full_common +from odoo.tests import common -class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS): +class LinkTrackerMock(common.BaseCase): + + def setUp(self): + super(LinkTrackerMock, self).setUp() + + def _compute_favicon(): + # 1px to avoid real request + return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' + + def _get_title_from_url(u): + return "Test_TITLE" + + self.env['ir.config_parameter'].sudo().set_param('web.base.url', 'https://test.odoo.com') + + link_tracker_favicon_patch = patch('odoo.addons.link_tracker.models.link_tracker.LinkTracker._compute_favicon', wraps=_compute_favicon) + link_tracker_title_patch = patch('odoo.addons.link_tracker.models.link_tracker.LinkTracker._get_title_from_url', wraps=_get_title_from_url) + link_tracker_favicon_patch.start() + link_tracker_title_patch.start() + self.addCleanup(link_tracker_favicon_patch.stop) + self.addCleanup(link_tracker_title_patch.stop) + + self.utm_c = self.env.ref('utm.utm_campaign_fall_drive') + self.utm_m = self.env.ref('mass_mailing_sms.utm_medium_sms') + self.tracker_values = { + 'campaign_id': self.utm_c.id, + 'medium_id': self.utm_m.id, + } + + def assertLinkTracker(self, url, url_params): + links = self.env['link.tracker'].sudo().search([('url', '=', url)]) + self.assertEqual(len(links), 1) + + # check UTMS are correctly set on redirect URL + original_url = werkzeug.urls.url_parse(url) + redirect_url = werkzeug.urls.url_parse(links.redirected_url) + redirect_params = redirect_url.decode_query().to_dict(flat=True) + self.assertEqual(redirect_url.scheme, original_url.scheme) + self.assertEqual(redirect_url.decode_netloc(), original_url.decode_netloc()) + self.assertEqual(redirect_url.path, original_url.path) + self.assertEqual(redirect_params, url_params) + + +class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, LinkTrackerMock): @classmethod def setUpClass(cls): @@ -24,6 +69,68 @@ class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS): 'body': cls._test_body, }) + def test_body_link_shorten(self): + link = 'http://www.example.com' + self.env['link.tracker'].search([('url', '=', link)]).unlink() + new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + self.assertNotIn(link, new_body) + self.assertLinkTracker(link, {'utm_campaign': self.utm_c.name, 'utm_medium': self.utm_m.name}) + link = self.env['link.tracker'].search([('url', '=', link)]) + self.assertIn(link.short_url, new_body) + + link = 'https://test.odoo.com/my/super_page' + self.env['link.tracker'].search([('url', '=', link)]).unlink() + new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + self.assertNotIn(link, new_body) + self.assertLinkTracker(link, {'utm_campaign': self.utm_c.name, 'utm_medium': self.utm_m.name}) + link = self.env['link.tracker'].search([('url', '=', link)]) + self.assertIn(link.short_url, new_body) + + def test_body_link_shorten_wshort(self): + link = 'https://test.odoo.com/r/RAOUL' + self.env['link.tracker'].search([('url', '=', link)]).unlink() + new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + self.assertIn(link, new_body) + self.assertFalse(self.env['link.tracker'].search([('url', '=', link)])) + + def test_body_link_shorten_wunsubscribe(self): + link = 'https://test.odoo.com/sms/3/' + self.env['link.tracker'].search([('url', '=', link)]).unlink() + new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + self.assertIn(link, new_body) + self.assertFalse(self.env['link.tracker'].search([('url', '=', link)])) + + def test_sms_body_link_shorten_suffix(self): + mailing = self.env['mailing.mailing'].create({ + 'name': 'Minimal mailing', + 'mailing_model_id': self.env['ir.model']._get('mail.test.sms').id, + 'mailing_type': 'sms', + }) + + sms_0 = self.env['sms.sms'].create({ + 'body': 'Welcome to https://test.odoo.com', + 'number': '12', + 'mailing_id': mailing.id, + }) + sms_1 = self.env['sms.sms'].create({ + 'body': 'Welcome to https://test.odoo.com/r/RAOUL', + 'number': '12', + }) + sms_2 = self.env['sms.sms'].create({ + 'body': 'Welcome to https://test.odoo.com/r/RAOUL', + 'number': '12', 'mailing_id': mailing.id, + }) + sms_3 = self.env['sms.sms'].create({ + 'body': 'Welcome to https://test.odoo.com/leodagan/r/RAOUL', + 'number': '12', 'mailing_id': mailing.id, + }) + + res = (sms_0 | sms_1 | sms_2 | sms_3)._update_body_short_links() + self.assertEqual(res[sms_0.id], 'Welcome to https://test.odoo.com') + self.assertEqual(res[sms_1.id], 'Welcome to https://test.odoo.com/r/RAOUL') + self.assertEqual(res[sms_2.id], 'Welcome to https://test.odoo.com/r/RAOUL/s/%s' % sms_2.id) + self.assertEqual(res[sms_3.id], 'Welcome to https://test.odoo.com/leodagan/r/RAOUL') + def test_sms_send_batch_size(self): self.count = 0