[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)
This commit is contained in:
Thibault Delavallée
2019-08-12 12:00:22 +00:00
parent c7a14e9109
commit 14648272da
36 changed files with 1807 additions and 4 deletions
+7
View File
@@ -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
+32
View File
@@ -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,
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+105
View File
@@ -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)
@@ -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>
@@ -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
@@ -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
@@ -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']
@@ -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()
@@ -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
@@ -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
+47
View File
@@ -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)
@@ -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>
@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -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>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
+73
View File
@@ -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()
@@ -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>
@@ -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>
@@ -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>
@@ -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': ['|', '&amp;', ('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': ['|', '&amp;', ('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>
@@ -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>
@@ -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>
@@ -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>
@@ -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
@@ -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
@@ -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>
@@ -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
@@ -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>
+2 -1
View File
@@ -15,7 +15,8 @@ real applications. """,
'mail',
'mail_bot',
# 'snailmail',
# 'mass_mailing',
'mass_mailing',
'mass_mailing_sms',
'phone_validation',
'sms',
],
+1
View File
@@ -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
+12
View File
@@ -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')
@@ -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
)
@@ -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')
+108 -1
View File
@@ -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