diff options
36 files changed, 1807 insertions, 4 deletions
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/<int:mailing_id>/<string:trace_code>'], 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/<int:mailing_id>/unsubscribe/<string:trace_code>'], 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/<string:code>/s/<int:sms_sms_id>', 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo><data noupdate="1"> + + <record model="utm.medium" id="utm_medium_sms"> + <field name="name">SMS</field> + </record> + +</data></odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="mailing_trace_report_view_pivot" model="ir.ui.view"> + <field name="name">mailing.trace.report.view.pivot</field> + <field name="model">mailing.trace.report</field> + <field name="arch" type="xml"> + <pivot string="Mass Mailing Statistics" disable_linking="True"> + <field name="campaign" type="row"/> + <field name="sent" type="measure"/> + <field name="delivered" type="measure"/> + <field name="opened" type="measure"/> + <field name="bounced" type="measure"/> + <field name="replied" type="measure"/> + <field name="clicked" type="measure"/> + </pivot> + </field> + </record> + + <record id="mailing_trace_report_view_graph" model="ir.ui.view"> + <field name="name">mailing.trace.report.view.graph</field> + <field name="model">mailing.trace.report</field> + <field name="arch" type="xml"> + <graph string="Mass Mailing Statistics"> + <field name="campaign"/> + <field name="sent" type="measure"/> + </graph> + </field> + </record> + + <record id="mailing_trace_report_view_search" model="ir.ui.view"> + <field name="name">mailing.trace.report.view.search</field> + <field name="model">mailing.trace.report</field> + <field name="arch" type="xml"> + <search string="Mass Mailing Statistics"> + <group expand="0" string="Extended Filters..."> + <field name="scheduled_date"/> + </group> + <group expand="1" string="Group By..."> + <filter string="Mass Mailing Campaign" name="mass_mailing_campaign" + domain="[]" context="{'group_by':'campaign'}"/> + <filter string="State" domain="[]" name="state" + context="{'group_by':'state'}"/> + <filter string="Sent By" domain="[]" name="sent_by" + context="{'group_by':'email_from'}"/> + <separator/> + <filter string="Scheduled Period" name="scheduled_date" + domain="[]" context="{'group_by':'scheduled_date'}"/> + </group> + </search> + </field> + </record> + + <!-- Actions and Menuitems --> + <record id="mailing_trace_report_action_sms" model="ir.actions.act_window"> + <field name="name">Mass SMS Analysis</field> + <field name="res_model">mailing.trace.report</field> + <field name="view_mode">graph,pivot</field> + <field name="domain">[('mailing_type', '=', 'sms')]</field> + <field name="help" type="html"> + <p class="o_view_nocontent_smiling_face"> + 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. + </p> + </field> + </record> + + <!-- Mass SMS / Reporting --> + <record id="mass_mailing_sms_menu_reporting" model="ir.ui.menu"> + <field name="action" ref="mailing_trace_report_action_sms"/> + </record> +</odoo> 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 Binary files differnew file mode 100644 index 00000000000..7dbb33addee --- /dev/null +++ b/addons/mass_mailing_sms/static/description/icon.png 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve"> +<style type="text/css"> + .st0{filter:url(#Adobe_OpacityMaskFilter);} + .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;} + .st2{mask:url(#icon-b_1_);} + .st3{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);} + .st4{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;fill-opacity:0.383;} + .st5{fill-rule:evenodd;clip-rule:evenodd;fill-opacity:0.151;} + .st6{fill-rule:evenodd;clip-rule:evenodd;fill-opacity:0.383;} + .st7{opacity:0.5;} + .st8{fill:#FFFFFF;} + .st9{filter:url(#Adobe_OpacityMaskFilter_1_);} + .st10{mask:url(#icon-b_2_);} + .st11{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);} + .st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);} +</style> +<defs> + <filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="-0.1" y="-0.2" width="70.1" height="70.2"> + <feColorMatrix type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/> + </filter> +</defs> +<mask maskUnits="userSpaceOnUse" x="-0.1" y="-0.2" width="70.1" height="70.2" id="icon-b_2_"> + <g class="st0"> + <path id="icon-a_2_" class="st1" d="M4,0c32.4,0,54.1,0,61,0c4,0,5,1,5,5c0,38,0,57.4,0,60c0,4-1,5-5,5S9,70,4,70c-3,0-4-1-4-5 + c0-2.5,0-23.2,0-60C0,1,1,0,4,0z"/> + </g> +</mask> +<g class="st10"> + + <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-636.7537" y1="415.0251" x2="-637.7537" y2="414.0389" gradientTransform="matrix(70 0 0 -70 44643 29052)"> + <stop offset="0" style="stop-color:#797C79"/> + <stop offset="1" style="stop-color:#545554"/> + </linearGradient> + <path class="st3" d="M0,0h70v70H0V0z"/> + <path class="st6" d="M4,69h61c2.7,0,4.3-1,5-3v4H0v-4C0.7,68,2,69,4,69z"/> + + <linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-636.7571" y1="415.0286" x2="-637.7571" y2="414.0286" gradientTransform="matrix(70 0 0 -70 44643 29052)"> + <stop offset="0" style="stop-color:#94B6C8"/> + <stop offset="1" style="stop-color:#6A9EBA"/> + </linearGradient> + <path class="st11" d="M0,0h70v70H0V0z"/> + <path class="st4" d="M4,1h61c2.7,0,4.3,0.7,5,2c0-2.7,0-3.7,0-3H0c0-0.7,0,0.3,0,3C0.7,1.7,2,1,4,1z"/> + <path class="st6" d="M4,69h61c2.7,0,4.3-1,5-3v4H0v-4C0.7,68,2,69,4,69z"/> + <g transform="translate(0 22)"> + <g> + <path id="icon-d_1_" class="st5" d="M4,46.9c-2,0-4-1-4-4.1V7l18.6-18.9h25.7l1.2,8.5l-1.9,1.9l11.1,7.6v8.3l-9.1,9.3l-0.7,11.7 + L32.2,46.9H4z"/> + </g> + </g> + <path class="st7" d="M45.4,12.1c0-1.1-0.9-2-2-2H20.1c-1.1,0-2,0.9-2,2v44.6c0,1.1,0.9,2,2,2h23.3c1.1,0,2-0.9,2-2V42.7 + c-0.7,0.5-1.5,1-2.3,1.4v9.3c0,0.2-0.1,0.3-0.3,0.3H20.7c-0.2,0-0.3-0.1-0.3-0.3v-37c0-0.2,0.1-0.3,0.3-0.3h22.1 + c0.2,0,0.3,0.1,0.3,0.3v2.8c0.5,0,1.1-0.1,1.6-0.1c0.2,0,0.5,0,0.7,0V12.1z M31.8,54.5c0.8,0,1.5,0.7,1.5,1.5 + c0,0.8-0.7,1.5-1.5,1.5c-0.8,0-1.5-0.7-1.5-1.5C30.3,55.2,31,54.5,31.8,54.5z M34.9,14.6h-6.1c-0.3,0-0.5-0.2-0.5-0.5 + s0.2-0.5,0.5-0.5h6.1c0.3,0,0.5,0.2,0.5,0.5S35.1,14.6,34.9,14.6z"/> + <path class="st8" d="M44.7,41.7c-0.5,0-1.1,0-1.6-0.1v11.1c0,0.2-0.1,0.3-0.3,0.3H20.7c-0.2,0-0.3-0.1-0.3-0.3v-37 + c0-0.2,0.1-0.3,0.3-0.3h22.1c0.2,0,0.3,0.1,0.3,0.3v2.9c0.5,0,1.1-0.1,1.6-0.1c0.2,0,0.5,0,0.7,0v-7.2c0-1.1-0.9-2-2-2H20.1 + c-1.1,0-2,0.9-2,2V56c0,1.1,0.9,2,2,2h23.3c1.1,0,2-0.9,2-2V41.7C45.2,41.7,44.9,41.7,44.7,41.7z M28.7,13h6.1 + c0.3,0,0.5,0.2,0.5,0.5S35.1,14,34.9,14h-6.1c-0.3,0-0.5-0.2-0.5-0.5S28.4,13,28.7,13z M31.8,56.9c-0.8,0-1.5-0.7-1.5-1.5 + c0-0.8,0.7-1.5,1.5-1.5c0.8,0,1.5,0.7,1.5,1.5C33.3,56.3,32.6,56.9,31.8,56.9z"/> +</g> +<path id="icon-e" class="st7" d="M44.7,40.6c-2.2,0-4.3-0.4-6.1-1.1c-1.8,1.5-4.1,2.3-6.5,2.6c0,0,0,0-0.1,0c-0.2,0-0.4-0.1-0.5-0.4 + c-0.1-0.2,0.1-0.4,0.3-0.5c0.9-0.9,1.9-1.5,2.4-4.4c-1.7-1.6-2.7-3.6-2.7-5.8c0-5.3,5.9-9.7,13.1-9.7s13.1,4.3,13.1,9.7 + C57.8,36.3,51.9,40.6,44.7,40.6z"/> +<path class="st8" d="M44.7,39.7c-2.2,0-4.3-0.4-6.1-1.1c-1.8,1.5-4.1,2.3-6.5,2.6c0,0,0,0-0.1,0c-0.2,0-0.4-0.1-0.5-0.4 + c-0.1-0.2,0.1-0.4,0.3-0.5c0.9-0.9,1.9-1.5,2.4-4.4c-1.7-1.6-2.7-3.6-2.7-5.8c0-5.3,5.9-9.7,13.1-9.7s13.1,4.3,13.1,9.7 + C57.8,35.4,51.9,39.7,44.7,39.7z"/> +</svg> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <menuitem id="link_tracker_menu" + name="Link Tracker" + parent="mass_mailing_sms_menu_configuration" + sequence="2" + action="link_tracker.link_tracker_action"/> +</odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="mailing_contact_view_search" model="ir.ui.view"> + <field name="name">mailing.contact.view.search.inherit.sms</field> + <field name="model">mailing.contact</field> + <field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_contact_search"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='name']" position="after"> + <field name="phone_sanitized"/> + </xpath> + <xpath expr="//filter[@name='filter_not_optout']" position="after"> + <separator/> + <filter string="Valid SMS Recipients" + name="filter_valid_sms_recipient" + domain="[('opt_out', '=', False), ('phone_blacklisted', '=', False), ('phone_sanitized', '!=', False)]" + invisible="not context.get('default_list_ids')"/> + <separator/> + <filter string="Exclude Blacklisted Phone" + name="filter_not_phone_bl" + domain="[('phone_blacklisted', '=', False)]"/> + </xpath> + </field> + </record> + + <record id="mailing_contact_view_tree_sms" model="ir.ui.view"> + <field name="name">mailing.contact.view.tree.sms</field> + <field name="model">mailing.contact</field> + <field name="priority">20</field> + <field name="arch" type="xml"> + <tree string="Contacts"> + <field name="create_date"/> + <field name="name"/> + <field name="company_name"/> + <field name="phone_sanitized"/> + <field name="phone_blacklisted"/> + <field name="opt_out" invisible="'default_list_ids' not in context"/> + </tree> + </field> + </record> + + <record id="mailing_contact_view_form" model="ir.ui.view"> + <field name="name">mailing.contact.view.form.inherit.sms</field> + <field name="model">mailing.contact</field> + <field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_contact_form"/> + <field name="arch" type="xml"> + <xpath expr="//label[@for='email']" position="attributes"> + <attribute name="invisible">context.get('mailing_sms') == True</attribute> + </xpath> + <xpath expr="//div[@name='email_details']" position="attributes"> + <attribute name="invisible">context.get('mailing_sms') == True</attribute> + </xpath> + <xpath expr="//div[@name='email_details']" position="after"> + <label for="phone_sanitized" class="oe_inline"/> + <div class="o_row o_row_readonly" name="phone_details"> + <i class="fa fa-ban" style="color: red;" role="img" title="This number is blacklisted for mass SMS" + aria-label="Phone Blacklisted" attrs="{'invisible': [('phone_blacklisted', '=', False)]}" groups="base.group_user"></i> + <field name="phone_sanitized" widget="phone"/> + <field name="phone_blacklisted" invisible="1"/> + </div> + </xpath> + </field> + </record> + + <record id="mailing_contact_view_kanban" model="ir.ui.view"> + <field name="name">mailing.contact.view.kanban.inherit.sms</field> + <field name="model">mailing.contact</field> + <field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_contact_kanban"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='email']" position="after"> + <field name="phone_sanitized"/> + </xpath> + <xpath expr="//t[@t-esc='record.email.value']" position="after"> + <t t-esc="record.phone_sanitized.value"/> + </xpath> + </field> + </record> + + <record id="mailing_contact_action_sms" model="ir.actions.act_window"> + <field name="name">Contacts</field> + <field name="res_model">mailing.contact</field> + <field name="view_mode">tree,form</field> + <field name="context">{'mailing_sms': True, 'search_default_filter_not_phone_bl': 1, }</field> + <field name="view_id" ref="mailing_contact_view_tree_sms"/> + <field name="help" type="html"> + <p class="o_view_nocontent_smiling_face"> + Create a new contact + </p> + </field> + </record> + + <!-- Mass SMS / Contacts Lists / Contacts Lists --> + <record id="mass_mailing_sms.mailing_contact_menu_sms" model="ir.ui.menu"> + <field name="action" ref="mailing_contact_action_sms"/> + </record> + +</odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="mailing_list_action_sms" model="ir.actions.act_window"> + <field name="name">Contact Lists</field> + <field name="res_model">mailing.list</field> + <field name="view_mode">kanban,tree,form</field> + <field name="context">{'mailing_sms': True}</field> + <field name="help" type="html"> + <p class="o_view_nocontent_smiling_face"> + Create a new contacts list + </p><p> + You don't need to import your contacts lists, you can easily + send SMS to any contact saved in other Odoo apps. + </p> + </field> + </record> + + <!-- Mass SMS / Contacts Lists / Contacts Lists --> + <record id="mass_mailing_sms.mailing_list_menu_sms" model="ir.ui.menu"> + <field name="action" ref="mailing_list_action_sms"/> + </record> +</odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo><data> + <record id="mailing_mailing_view_form" model="ir.ui.view"> + <field name="name">mailing.mailing.view.form.inherit.sms</field> + <field name="model">mailing.mailing</field> + <field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_form"/> + <field name="arch" type="xml"> + <!-- Buttons / Actions --> + <xpath expr="//button[@name='action_put_in_queue']" position="attributes"> + <attribute name="attrs">{'invisible': ['|', ('state', 'in', ('in_queue', 'done')), ('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//button[@name='action_put_in_queue']" position="after"> + <button name="action_put_in_queue" type="object" + class="oe_highlight" string="Send Now" + attrs="{'invisible': ['|', ('mailing_type', '=', 'mail'), ('state', 'in', ('in_queue', 'done'))]}" + confirm="This will send SMS to all recipients. Do you still want to proceed ?"/> + </xpath> + <!-- Headers / Warnings --> + <xpath expr="//header" position="after"> + <field name="sms_has_insufficient_credit" invisible="1"/> + <div class="alert alert-warning text-center" attrs="{'invisible': [('sms_has_insufficient_credit', '=', False)]}" role="alert"> + <p> + <button class="btn-link" + name="action_buy_sms_credits" + type="object"> + <strong> + It appears you don't have enough IAP credits. Click here to buy credits. + </strong> + </button> + </p> + </div> + </xpath> + <xpath expr="//span[@name='ignored_text']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//span[@name='ignored_text']" position="after"> + <span name="ignored_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message have been ignored and will not be sent.</span> + </xpath> + <xpath expr="//span[@name='scheduled_text']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//span[@name='scheduled_text']" position="after"> + <span name="scheduled_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message are in queue and will be sent soon.</span> + </xpath> + <xpath expr="//span[@name='failed_text']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//span[@name='failed_text']" position="after"> + <span name="failed_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message could not be sent.</span> + </xpath> + <!-- Stat Buttons --> + <xpath expr="//button[@id='button_view_sent']" position="attributes"> + <attribute name="attrs">{'invisible': ['|', '&', ('sent', '=', 0), ('state', 'in', ('draft', 'test')), ('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//button[@id='button_view_sent']" position="after"> + <button name="action_view_sent" + type="object" + context="{'search_default_filter_sent': True}" + icon="fa-comment-o" class="oe_stat_button" + attrs="{'invisible': ['|', '&', ('sent', '=', 0), ('state', 'in', ('draft', 'test')), ('mailing_type', '!=', 'sms')]}" > + <field name="sent" string="Sent" widget="statinfo"/> + </button> + </xpath> + <xpath expr="//button[@name='action_view_opened']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//button[@name='action_view_replied']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <!-- Form --> + <xpath expr="//field[@name='subject']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//page[@name='mail_body']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//page[@name='mail_body']" position="after"> + <page string="SMS Content" name="sms_body" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}"> + <field name="body_plaintext" widget="sms_widget"/> + <group> + <field name="sms_template_id" domain="[('model', '=', mailing_model_real)]"/> + <field name="sms_allow_unsubscribe"/> + </group> + </page> + </xpath> + <!-- Option page tweaks --> + <xpath expr="//field[@name='email_from']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//label[@for='reply_to']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//div[@name='reply_to_details']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//label[@for='attachment_ids']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//div[@name='attachment_ids_details']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//field[@name='mail_server_id']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + </field> + </record> + + <record id="mailing_mailing_view_kanban" model="ir.ui.view"> + <field name="name">mailing.mailing.view.kanban.inherit.sms</field> + <field name="model">mailing.mailing</field> + <field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_kanban"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='active']" position="after"> + <field name="sms_has_insufficient_credit"/> + </xpath> + <xpath expr="//h3" position="after"> + <div class="alert alert-warning" role="alert" attrs="{'invisible': [('sms_has_insufficient_credit', '=', False)]}"> + <a name="action_buy_sms_credits" + type="object">Insufficient credits</a> + </div> + </xpath> + <xpath expr="//div[@name='stat_opened']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//div[@name='stat_replied']" position="attributes"> + <attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute> + </xpath> + </field> + </record> + + <record id="mailing_mailing_view_tree_sms" model="ir.ui.view"> + <field name="name">mailing.mailing.view.tree.sms</field> + <field name="model">mailing.mailing</field> + <field name="priority">20</field> + <field name="arch" type="xml"> + <tree string="Mass SMS"> + <field name="name"/> + <field name="mailing_type" invisible="1"/> + <field name="sent"/> + <field name="clicked"/> + <field name="bounced"/> + <field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/> + </tree> + </field> + </record> + + <record id="mailing_mailing_action_sms" model="ir.actions.act_window"> + <field name="name">Mass SMS</field> + <field name="res_model">mailing.mailing</field> + <field name="view_mode">kanban,tree,form,graph</field> + <field name="domain">[('mailing_type', '=', 'sms')]</field> + <field name="context">{ + 'search_default_assigned_to_me': 1, + 'default_user_id': uid, + 'default_mailing_type': 'sms', + }</field> + <field name="help" type="html"> + <p class="o_view_nocontent_smiling_face"> + Create a new mass SMS + </p><p> + You can easily send SMS to any contact saved in other Odoo apps. + </p> + </field> + </record> + <record id="mailing_mailing_action_sms_view_kanban" model="ir.actions.act_window.view"> + <field name="sequence">1</field> + <field name="view_mode">kanban</field> + <field name="view_id" ref="mass_mailing.view_mail_mass_mailing_kanban"/> + <field name="act_window_id" ref="mailing_mailing_action_sms"/> + </record> + <record id="mailing_mailing_action_sms_view_tree" model="ir.actions.act_window.view"> + <field name="sequence">2</field> + <field name="view_mode">tree</field> + <field name="view_id" ref="mailing_mailing_view_tree_sms"/> + <field name="act_window_id" ref="mailing_mailing_action_sms"/> + </record> + + <!-- Mass SMS / Mass SMS --> + <record id="mass_mailing_sms_menu_mass_sms" model="ir.ui.menu"> + <field name="action" ref="mailing_mailing_action_sms"/> + </record> + +</data></odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <!-- Mass SMS --> + <menuitem id="mass_mailing_sms_menu_root" + name="Mass SMS" + sequence="60" + web_icon="mass_mailing_sms,static/description/icon.png" + groups="mass_mailing.group_mass_mailing_user"/> + + <!-- Mass SMS / Mass SMS --> + <menuitem id="mass_mailing_sms_menu_mass_sms" + name="Mass SMS" + parent="mass_mailing_sms_menu_root" + sequence="1" + groups="mass_mailing.group_mass_mailing_user"/> + + <!-- Mass SMS / Contacts Lists --> + <menuitem id="mass_mailing_sms_menu_contacts" + name="Contacts Lists" + parent="mass_mailing_sms_menu_root" + sequence="2" + groups="mass_mailing.group_mass_mailing_user"/> + <!-- Mass SMS / Contacts Lists / Contacts Lists --> + <menuitem id="mailing_list_menu_sms" + name="Contacts Lists" + parent="mass_mailing_sms_menu_contacts" + sequence="1" + groups="mass_mailing.group_mass_mailing_user"/> + <!-- Mass SMS / Contacts Lists / Contacts --> + <menuitem id="mailing_contact_menu_sms" + name="Contacts" + parent="mass_mailing_sms_menu_contacts" + sequence="2" + groups="mass_mailing.group_mass_mailing_user"/> + + <!-- Mass SMS / Reporting --> + <menuitem id="mass_mailing_sms_menu_reporting" + name="Reporting" + parent="mass_mailing_sms_menu_root" + sequence="80" + groups="mass_mailing.group_mass_mailing_user"/> + + <!-- Mass SMS / Configuration --> + <menuitem id="mass_mailing_sms_menu_configuration" + name="Configuration" + parent="mass_mailing_sms_menu_root" + sequence="100" + groups="mass_mailing.group_mass_mailing_user"/> + <!-- Mass SMS / Configuration / Blacklist --> + <menuitem id="phone_blacklist_menu" + name="Phone Blacklist" + parent="mass_mailing_sms_menu_configuration" + sequence="1" + action="phone_validation.phone_blacklist_action" + groups="mass_mailing.group_mass_mailing_user"/> + <!-- Mass SMS / Configuration / Link Tracker --> + <menuitem id="link_tracker_menu" + name="Link Tracker Blacklist" + parent="mass_mailing_sms_menu_configuration" + sequence="2" + action="link_tracker.link_tracker_action" + groups="mass_mailing.group_mass_mailing_user"/> +</odoo>
\ 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="mailing_trace_view_search" model="ir.ui.view"> + <field name="name">mailing.trace.view.search.inherit.sms</field> + <field name="model">mailing.trace</field> + <field name="inherit_id" ref="mass_mailing.mailing_trace_view_search"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='email']" position="after"> + <field name="sms_sms_id_int"/> + <field name="sms_sms_id"/> + <field name="sms_number"/> + </xpath> + </field> + </record> + + <record id="mailing_trace_view_tree" model="ir.ui.view"> + <field name="name">mailing.trace.view.tree.inherit.sms</field> + <field name="model">mailing.trace</field> + <field name="inherit_id" ref="mass_mailing.mailing_trace_view_tree"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='email']" position="before"> + <field name="trace_type"/> + </xpath> + <xpath expr="//field[@name='email']" position="after"> + <field name="sms_number"/> + </xpath> + </field> + </record> + + <record id="mailing_trace_view_tree_sms" model="ir.ui.view"> + <field name="name">mailing.trace.view.tree.sms</field> + <field name="model">mailing.trace</field> + <field name="priority">20</field> + <field name="arch" type="xml"> + <tree string="SMS Traces" create="0"> + <field name="sms_number"/> + <field name="state"/> + <field name="scheduled"/> + <field name="sent"/> + <field name="clicked"/> + <field name="bounced"/> + <field name="exception"/> + <field name="ignored"/> + <field name="failure_type"/> + </tree> + </field> + </record> + + <record id="mailing_trace_view_form" model="ir.ui.view"> + <field name="name">mailing.trace.view.form.inherit.sms</field> + <field name="model">mailing.trace</field> + <field name="inherit_id" ref="mass_mailing.mailing_trace_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//span[@name='trace_type_name_mail']" position="attributes"> + <attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//span[@name='trace_type_name_mail']" position="after"> + <span name="trace_type_name_sms" attrs="{'invisible': [('trace_type', '!=', 'sms')]}">This sms</span> + </xpath> + <xpath expr="//field[@name='email']" position="attributes"> + <attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//field[@name='mail_mail_id_int']" position="attributes"> + <attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//field[@name='message_id']" position="attributes"> + <attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute> + </xpath> + <xpath expr="//field[@name='opened']" position="attributes"> + <attribute name="attrs">{'invisible': [('trace_type', '=', 'sms')]}</attribute> + </xpath> + <xpath expr="//field[@name='replied']" position="attributes"> + <attribute name="attrs">{'invisible': [('trace_type', '=', 'sms')]}</attribute> + </xpath> + <xpath expr="//field[@name='email']" position="after"> + <field name="sms_number" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/> + <field name="sms_sms_id_int" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/> + <field name="sms_code" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/> + </xpath> + </field> + </record> + + <record id="mailing_trace_view_form_sms" model="ir.ui.view"> + <field name="name">mailing.trace.view.form.sms</field> + <field name="model">mailing.trace</field> + <field name="priority">20</field> + <field name="arch" type="xml"> + <form string="SMS Trace" create="0"> + <header> + <field name="state" widget="statusbar"/> + </header> + <sheet> + <div class="alert alert-info text-center" attrs="{'invisible': [('exception', '=', False)]}" role="alert"> + <strong>This SMS could not be sent.</strong> + </div> + <div class="alert alert-info text-center" attrs="{'invisible': [('bounced', '=', False)]}" role="alert"> + <strong>This number appears to be invalid.</strong> + </div> + <group> + <group> + <field name="sms_number"/> + <field name="mass_mailing_id"/> + <field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/> + <field name="sms_sms_id_int" groups="base.group_no_one"/> + <field name="model" groups="base.group_no_one"/> + <field name="res_id" groups="base.group_no_one"/> + </group> + <group> + <field name="scheduled"/> + <field name="sent"/> + <field name="clicked"/> + <field name="bounced"/> + <field name="exception"/> + <field name="ignored"/> + </group> + </group> + </sheet> + </form> + </field> + </record> +</odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="blacklist_main" name="Unsubscribed"> + <t t-call="portal.frontend_layout"> + <div class="container mb64"> + <div class="row"> + <div class="col-lg-6 offset-lg-3"> + <h3>SMS Subscription</h3> + + <form t-att-action="'/sms/%s/unsubscribe/%s' % (mailing_id, trace_code)" method="post"> + <p>Please enter your phone number</p> + <div class="form-group row"> + <label for="sms_number" class="col-sm-2 col-form-label">Number</label> + <div class="col-sm-10"> + <input type="text" class="form-control" name="sms_number" id="sms_number"/> + </div> + </div> + <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> + <input type="hidden" name="trace_code" t-att-value="trace_code"/> + <input type="hidden" name="mailing_id" t-att-value="mailing_id"/> + <div class="form-group row"> + <div class="col-sm-10 offset-sm-2"> + <button type="submit" class="btn btn-primary">Unsubscribe me</button> + </div> + </div> + </form> + </div> + </div> + </div> + </t> + </template> + + <template id="blacklist_number" name="Unsubscribed"> + <t t-call="portal.frontend_layout"> + <div class="container mb64"> + <div class="row"> + <div class="col-lg-6 offset-lg-3"> + <h3>SMS Subscription</h3> + + <div t-if="unsubscribe_error" class="alert alert-danger text-center" role="alert"> + <p>There was an error when trying to unsubscribe <strong t-esc="sms_number"/></p> + <p t-esc="unsubscribe_error"/> + </div> + <div t-else="" class="alert alert-success text-center" role="status"> + <t t-if="lists_optout"> + <p><strong t-esc="sms_number"/> has been successfully removed from</p> + <t t-foreach="lists_optout" t-as="list_id"> + <strong t-esc="list_id.name"/><br /> + </t> + </t> + <p t-else=""> + <strong t-esc="sms_number"/> has been successfully blacklisted + </p> + </div> + </div> + </div> + </div> + </t> + </template> +</odoo> 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 @@ +<?xml version="1.0"?> +<odoo> + <record id="mailing_sms_test_view_form" model="ir.ui.view"> + <field name="name">mailing.sms.test.view.form</field> + <field name="model">mailing.sms.test</field> + <field name="arch" type="xml"> + <form string="Send a Sample SMS"> + <p class="text-muted"> + Send a sample SMS for testing purpose to the numbers below. + </p> + <group> + <field name="numbers"/> + <field name="sanitized_numbers" invisible="0"/> + <field name="mailing_id" invisible="1"/> + </group> + <footer> + <button string="Send" name="action_send_sms" type="object" class="btn-primary"/> + <button string="Cancel" class="btn btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + <record id="mailing_sms_test_action" model="ir.actions.act_window"> + <field name="name">Test SMS Mailing</field> + <field name="res_model">mailing.sms.test</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> +</odoo> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="sms_composer_view_form" model="ir.ui.view"> + <field name="name">sms.composer.views.inherit.sms</field> + <field name="model">sms.composer</field> + <field name="inherit_id" ref="sms.sms_composer_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='res_model']" position="after"> + <field name="utm_campaign_id" groups="mass_mailing.group_mass_mailing_campaign" + invisible="1"/> + <field name="mailing_id" invisible="1"/> + </xpath> + </field> + </record> + +</odoo> 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 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) -class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS): + # 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 |
