From 14648272dadfbc9e2c7069de76770d5c052cd37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 30 Jul 2019 12:06:41 +0000 Subject: [PATCH] [ADD] mass_mailing_sms: support SMS in mass mailing PURPOSE SMS are a powerful marketing tool. For instance it is perfect to announce a sale or to communicate a coupon code, to welcome a new customer in a fidelity program, ... Purpose of this task is to integrate SMS sending in batch in mass mailing. It will use same mailing objects but sending SMS instead of emails. Some metrics and flows will have to be slightly updated at the same time. SPECIFICATIONS Create a new application called "Mass SMS" that follows the structure of mass_mailing application. General guidelines * use the fa-comment icon for this module; * add a "Notification type" field to the mailings: email or sms. Set it invisible and set as domain from context when choosing mass mailing or mass sms application (menus / action specific); * adapt the metrics from Mass Mailing to SMS * sent = received = whether the SMS was sent or not; * opened => we don't have this information; * replied => we don't have this information; * clicks = from link tracker (no change of behavior); * bounced = SMS that failed to be delivered because the format is wrong; * exception = any issue when sending SMS; * keep the Mailing Lists menu, hide email related fields on contact model; * settings: hide the "Specific Mail Server" feature Mailing form view * add an "SMS Content" tab between Mail Body and Options. In there, we should have the following fields: * content * opt-out link: provide a link to the Unsubscribe page. Make it as short as possible; * SMS Template * add an "SMS Text Message" Medium; Mailing actions * add an "SMS Sent" smart button. Use the fa-comment-o icon and it should redirect to the same list views as the "Email sent" smart button and list the contacts to which an SMS was sent; * add a "Mobile" field in the list views of the "Email sent" smart button: * adapt the TEST button > Test Mailing modal to SMS (we should have 1 button for Email and 1 for SMS); update description; * default value for the Recipients field should be Name of the current user, work mobile/phone number; * If there are no Work Mobile/Phone numbers set on res.users, display (123)-456-7890 instead; * rename the Send Sample Mail button into Send Sample SMS; * adapt the SEND NOW button > Confirmation modal to SMS. Change the message into "This will send the SMS Text Message to all recipients. Do you still want to proceed?" * hide the following elements: Subject, From, Reply to, Attachments, Mail Server, Mail Body tab; Opened, Replied; * adapt the label of the following "warnings" to SMS * x SMS Text Message(s) have been ignored and will not be sent. * x SMS Text Message(s) are in queue and will be sent soon. * x SMS Text Message(s) could not be sent. Mailing kanban view * hide the following stats: https://nimb.ws/grvBn0: Opened, Replied; Mailing list / contact management * following fields should be left empty: https://nimb.ws/3HBeJL: Opened, Replied; * when browsing through mailing lsits and contacts through the Mass SMS application, update actions and views to hide fields related to email and display only phone-related information; UTM Campaign * form view > Related Mailings tab: the following fields should be left empty if notification type = SMS: https://nimb.ws/nfvr1O: Opened, Replied Mass Mailing > Reporting (mail.trace.report model) * adapt the reporting based on the fact that we do not have the following metrics for SMS: Opened, Replied; Mass Mailing > Configuration > Blacklist * do not display any email information (phone blacklist is about phone); * support phone blacklist. Phone blacklist is therefore a separate model from mail.blacklist to simplify management. It is included in sms sending process like mail blacklist when mailing in mass; Opt-out and blacklist handling * we do support mailing lists because they could come from different sources and are a way to communicate with a specific audience (and not just a list we bought), e.g. subscribe to this list to be kept updated whenever this product is back in stock, or to receive results of this ... * if the SMSing is from a list, opt-out removes ourselves from the list; * if it is from the contact ==> Sent to blacklist; * allow to attach unsubscription links in sent SMS, redirecting to a new route in mass mailing SMS allowing quick unsubscribe and/or blacklist of phone numbers; Error management * if the user clicks on Send Now / Schedule / Test and does not have enough credits, open the "Insufficient credits" modal in a lazy option (aka when having failed statistics linked to insufficient credits); * on the mailing, we however display this failure as : * a message on the mailing kanban view * a message on the top of the form view + same behavior as the mailing and "Could not be sent" https://www.screencast.com/t/BExgd1Gc; LINKS Task 1997464 PR #34424 Original SMS addition: Task 1922163 (4287481) --- addons/mass_mailing_sms/__init__.py | 7 + addons/mass_mailing_sms/__manifest__.py | 32 +++ .../mass_mailing_sms/controllers/__init__.py | 4 + addons/mass_mailing_sms/controllers/main.py | 105 ++++++++++ addons/mass_mailing_sms/data/utm_data.xml | 8 + addons/mass_mailing_sms/models/__init__.py | 9 + .../mass_mailing_sms/models/link_tracker.py | 33 +++ .../models/mailing_contact.py | 14 ++ .../mass_mailing_sms/models/mailing_list.py | 36 ++++ .../models/mailing_mailing.py | 193 ++++++++++++++++++ .../mass_mailing_sms/models/mailing_trace.py | 80 ++++++++ addons/mass_mailing_sms/models/sms_sms.py | 47 +++++ .../report/mailing_trace_report_views.xml | 71 +++++++ .../security/ir.model.access.csv | 1 + .../static/description/icon.png | Bin 0 -> 4338 bytes .../static/description/icon.svg | 70 +++++++ addons/mass_mailing_sms/tests/__init__.py | 4 + addons/mass_mailing_sms/tests/common.py | 73 +++++++ .../views/link_tracker_views.xml | 8 + .../views/mailing_contact_views.xml | 96 +++++++++ .../views/mailing_list_views.xml | 22 ++ .../views/mailing_mailing_views.xml | 183 +++++++++++++++++ .../views/mailing_sms_menus.xml | 63 ++++++ .../views/mailing_trace_views.xml | 121 +++++++++++ .../mass_mailing_sms_templates_portal.xml | 60 ++++++ addons/mass_mailing_sms/wizard/__init__.py | 5 + .../wizard/mailing_sms_test.py | 40 ++++ .../wizard/mailing_sms_test_views.xml | 30 +++ .../mass_mailing_sms/wizard/sms_composer.py | 99 +++++++++ .../wizard/sms_composer_views.xml | 16 ++ addons/test_mail_full/__manifest__.py | 3 +- addons/test_mail_full/tests/__init__.py | 1 + addons/test_mail_full/tests/common.py | 12 ++ addons/test_mail_full/tests/test_mass_sms.py | 152 ++++++++++++++ .../tests/test_sms_performance.py | 4 +- addons/test_mail_full/tests/test_sms_sms.py | 109 +++++++++- 36 files changed, 1807 insertions(+), 4 deletions(-) create mode 100644 addons/mass_mailing_sms/__init__.py create mode 100644 addons/mass_mailing_sms/__manifest__.py create mode 100644 addons/mass_mailing_sms/controllers/__init__.py create mode 100644 addons/mass_mailing_sms/controllers/main.py create mode 100644 addons/mass_mailing_sms/data/utm_data.xml create mode 100644 addons/mass_mailing_sms/models/__init__.py create mode 100644 addons/mass_mailing_sms/models/link_tracker.py create mode 100644 addons/mass_mailing_sms/models/mailing_contact.py create mode 100644 addons/mass_mailing_sms/models/mailing_list.py create mode 100644 addons/mass_mailing_sms/models/mailing_mailing.py create mode 100644 addons/mass_mailing_sms/models/mailing_trace.py create mode 100644 addons/mass_mailing_sms/models/sms_sms.py create mode 100644 addons/mass_mailing_sms/report/mailing_trace_report_views.xml create mode 100644 addons/mass_mailing_sms/security/ir.model.access.csv create mode 100644 addons/mass_mailing_sms/static/description/icon.png create mode 100644 addons/mass_mailing_sms/static/description/icon.svg create mode 100644 addons/mass_mailing_sms/tests/__init__.py create mode 100644 addons/mass_mailing_sms/tests/common.py create mode 100644 addons/mass_mailing_sms/views/link_tracker_views.xml create mode 100644 addons/mass_mailing_sms/views/mailing_contact_views.xml create mode 100644 addons/mass_mailing_sms/views/mailing_list_views.xml create mode 100644 addons/mass_mailing_sms/views/mailing_mailing_views.xml create mode 100644 addons/mass_mailing_sms/views/mailing_sms_menus.xml create mode 100644 addons/mass_mailing_sms/views/mailing_trace_views.xml create mode 100644 addons/mass_mailing_sms/views/mass_mailing_sms_templates_portal.xml create mode 100644 addons/mass_mailing_sms/wizard/__init__.py create mode 100644 addons/mass_mailing_sms/wizard/mailing_sms_test.py create mode 100644 addons/mass_mailing_sms/wizard/mailing_sms_test_views.xml create mode 100644 addons/mass_mailing_sms/wizard/sms_composer.py create mode 100644 addons/mass_mailing_sms/wizard/sms_composer_views.xml create mode 100644 addons/test_mail_full/tests/test_mass_sms.py 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 0000000000000000000000000000000000000000..7dbb33addee23d7a77d8d6ab23e9c0ecd3bd55d8 GIT binary patch literal 4338 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 literal 0 HcmV?d00001 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