[FW][MERGE] digest, mass_mailing: fix usage of digests / statistics emails

Provide fixes related to digest and mass mailing statistics email after some
testing.

Fixes notably
  * use queue to send emails;
  * ease unsubscribe for non admin people using a route and a token to allow
    unsubscribe when being not logged;
  * improve tone down of digests when users have no activity, gradually going
    to quarterly digests instead of sticking on weekly;
  * fix statistics emails being broken due to some change in digest layout;
  * fix statistics emails author / from / to computation;
  * fix unsubscribe display and usage;
  * fix 'body' part of digest location;

Tests are added in digest and mass mailing to test a bit more in-depth those
features.

See sub commits for more details.

Task-2641394 (Digest emails sending improvement)
Task-2582128 (Digest onbarding and usage improvement)
Task-2686586 (Repair mailing statistics email)
Task-2688856 (Digest tone down improvement)
Task-2692316 (Mailing trace status overrides)

closes odoo/odoo#79877

Forward-port-of: odoo/odoo#77609
Forward-port-of: odoo/odoo#76591
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-11-16 17:58:41 +01:00
committed by GitHub
17 changed files with 547 additions and 112 deletions
+19 -6
View File
@@ -1,21 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import Forbidden
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.urls import url_encode
from odoo import _
from odoo.http import Controller, request, route
from odoo.tools import consteq
class DigestController(Controller):
@route('/digest/<int:digest_id>/unsubscribe', type='http', website=True, auth='user')
def digest_unsubscribe(self, digest_id):
digest = request.env['digest.digest'].browse(digest_id).exists()
digest.action_unsubcribe()
@route('/digest/<int:digest_id>/unsubscribe', type='http', website=True, auth='public')
def digest_unsubscribe(self, digest_id, token=None, user_id=None):
digest_sudo = request.env['digest.digest'].sudo().browse(digest_id).exists()
# new route parameters
if digest_sudo and token and user_id:
correct_token = digest_sudo._get_unsubscribe_token(int(user_id))
if not consteq(correct_token, token):
raise NotFound()
digest_sudo._action_unsubscribe_users(request.env['res.users'].sudo().browse(int(user_id)))
# old route was given without any token or user_id but only for auth users
elif digest_sudo and not token and not user_id and not request.env.user.share:
digest_sudo.action_unsubcribe()
else:
raise NotFound()
return request.render('digest.portal_digest_unsubscribed', {
'digest': digest,
'digest': digest_sudo,
})
@route('/digest/<int:digest_id>/set_periodicity', type='http', website=True, auth='user')
+20 -5
View File
@@ -391,21 +391,36 @@
</div>
</div>
<div t-if="preferences">
<t t-if="body" t-out="body"/>
<div>
<div class="global_layout">
<div class="preference_div">
<div t-foreach="preferences" t-as="preference" class="preference">
<div t-if="preferences" t-foreach="preferences" t-as="preference"
class="preference">
<t t-out="preference"/>
</div>
<div class="by_odoo">
Sent by <a href="https://www.odoo.com" target="_blank" class="odoo_link"><span class="odoo_link_text">Odoo</span></a> –
<a t-att-href="'/web#view_type=form&amp;model=digest.digest&amp;id=%s' % object.id" target="_blank" style="text-decoration: none;"><span style="color: #8f8f8f;">Unsubscribe</span></a>
Sent by <a href="https://www.odoo.com" target="_blank" class="odoo_link"><span class="odoo_link_text">Odoo</span></a>
<t t-if="unsubscribe_token">
–
<a t-attf-href="/digest/#{object.id}/unsubscribe?token=#{unsubscribe_token}&amp;user_id=#{user.id}"
target="_blank" style="text-decoration: none;">
<span style="color: #8f8f8f;">Unsubscribe</span>
</a>
</t>
<t t-elif="object and object._name == 'digest.digest'">
–
<a t-att-href="'/web#view_type=form&amp;model=digest.digest&amp;id=%s' % object.id"
target="_blank" style="text-decoration: none;">
<span style="color: #8f8f8f;">Unsubscribe</span>
</a>
</t>
</div>
</div>
</div>
</div>
<t t-if="body" t-out="body"/>
<div t-if="display_mobile_banner" t-call="digest.digest_section_mobile" />
<div class="global_layout" id="footer">
+53 -12
View File
@@ -7,7 +7,6 @@ import pytz
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from werkzeug.urls import url_join
from odoo import api, fields, models, tools, _
from odoo.addons.base.models.ir_mail_server import MailDeliveryException
@@ -41,6 +40,7 @@ class Digest(models.Model):
kpi_mail_message_total = fields.Boolean('Messages')
kpi_mail_message_total_value = fields.Integer(compute='_compute_kpi_mail_message_total_value')
@api.depends('user_ids')
def _compute_is_subscribed(self):
for digest in self:
digest.is_subscribed = self.env.user in digest.user_ids
@@ -87,11 +87,21 @@ class Digest(models.Model):
def action_subscribe(self):
if self.env.user.has_group('base.group_user') and self.env.user not in self.user_ids:
self.sudo().user_ids |= self.env.user
self._action_subscribe_users(self.env.user)
def _action_subscribe_users(self, users):
""" Private method to manage subscriptions. Done as sudo() to speedup
computation and avoid ACLs issues. """
self.sudo().user_ids |= users
def action_unsubcribe(self):
if self.env.user.has_group('base.group_user') and self.env.user in self.user_ids:
self.sudo().user_ids -= self.env.user
self._action_unsubscribe_users(self.env.user)
def _action_unsubscribe_users(self, users):
""" Private method to manage subscriptions. Done as sudo() to speedup
computation and avoid ACLs issues. """
self.sudo().user_ids -= users
def action_activate(self):
self.state = 'activated'
@@ -111,7 +121,7 @@ class Digest(models.Model):
lang=user.lang
)._action_send_to_user(user, tips_count=1)
if digest in to_slowdown:
digest.write({'periodicity': 'weekly'})
digest.write({'periodicity': self._get_next_periodicity()[0]})
digest.next_run_date = digest._get_next_run_date()
def _action_send_to_user(self, user, tips_count=1, consum_tips=True):
@@ -126,6 +136,7 @@ class Digest(models.Model):
'top_button_url': self.get_base_url(),
'company': user.company_id,
'user': user,
'unsubscribe_token': self._get_unsubscribe_token(user.id),
'tips_count': tips_count,
'formatted_date': datetime.today().strftime('%B %d, %Y'),
'display_mobile_banner': True,
@@ -145,14 +156,15 @@ class Digest(models.Model):
)
# create a mail_mail based on values, without attachments
mail_values = {
'subject': '%s: %s' % (user.company_id.name, self.name),
'auto_delete': True,
'author_id': self.env.user.partner_id.id,
'email_from': self.company_id.partner_id.email_formatted if self.company_id else self.env.user.email_formatted,
'email_to': user.email_formatted,
'body_html': full_mail,
'auto_delete': True,
'state': 'outgoing',
'subject': '%s: %s' % (user.company_id.name, self.name),
}
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send(raise_exception=False)
self.env['mail.mail'].sudo().create(mail_values)
return True
@api.model
@@ -164,6 +176,14 @@ class Digest(models.Model):
except MailDeliveryException as e:
_logger.warning('MailDeliveryException while sending digest %d. Digest is now scheduled for next cron update.', digest.id)
def _get_unsubscribe_token(self, user_id):
"""Generate a secure hash for this digest and user. It allows to
unsubscribe from a digest while keeping some security in that process.
:param int user_id: ID of the user to unsubscribe
"""
return tools.hmac(self.env(su=True), 'digest-unsubscribe', (self.id, user_id))
# ------------------------------------------------------------
# KPIS
# ------------------------------------------------------------
@@ -256,7 +276,11 @@ class Digest(models.Model):
"""
preferences = []
if self._context.get('digest_slowdown'):
preferences.append(_("We have noticed you did not connect these last few days so we've automatically switched your preference to weekly Digests."))
_dummy, new_perioridicy_str = self._get_next_periodicity()
preferences.append(
_("We have noticed you did not connect these last few days. We have automatically switched your preference to %(new_perioridicy_str)s Digests.",
new_perioridicy_str=new_perioridicy_str)
)
elif self.periodicity == 'daily' and user.has_group('base.group_erp_manager'):
preferences.append(Markup('<p>%s<br /><a href="%s" target="_blank" style="color:#875A7B; font-weight: bold;">%s</a></p>') % (
_('Prefer a broader overview ?'),
@@ -318,17 +342,34 @@ class Digest(models.Model):
return margin
def _check_daily_logs(self):
three_days_ago = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - relativedelta(days=3)
""" Badly named method that checks user logs and slowdown the sending
of digest emails based on recipients being away. """
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
to_slowdown = self.env['digest.digest']
for digest in self.filtered(lambda digest: digest.periodicity == 'daily'):
for digest in self:
if digest.periodicity == 'daily': # 3 days ago
limit_dt = today - relativedelta(days=3)
elif digest.periodicity == 'weekly': # 2 weeks ago
limit_dt = today - relativedelta(days=14)
elif digest.periodicity == 'monthly': # 1 month ago
limit_dt = today - relativedelta(months=1)
elif digest.periodicity == 'quarterly': # 3 month ago
limit_dt = today - relativedelta(months=3)
users_logs = self.env['res.users.log'].sudo().search_count([
('create_uid', 'in', digest.user_ids.ids),
('create_date', '>=', three_days_ago)
('create_date', '>=', limit_dt)
])
if not users_logs:
to_slowdown += digest
return to_slowdown
def _get_next_periodicity(self):
if self.periodicity == 'weekly':
return 'monthly', _('monthly')
if self.periodicity == 'monthly':
return 'quarterly', _('quarterly')
return 'weekly', _('weekly')
def _format_currency_amount(self, amount, currency_id):
pre = currency_id.position == 'before'
symbol = u'{symbol}'.format(symbol=currency_id.symbol or '')
+3
View File
@@ -1 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_digest
+216 -40
View File
@@ -1,46 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
import random
from dateutil.relativedelta import relativedelta
from lxml import html
from werkzeug.urls import url_encode
from odoo import fields
from odoo import fields, SUPERUSER_ID
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.addons.mail.tests import common as mail_test
from odoo.tests import tagged
from odoo.tests.common import users
class TestDigest(mail_test.MailCommon):
def test_digest_numbers(self):
self._setup_messages()
digest = self.env['digest.digest'].create({
@classmethod
def setUpClass(cls):
super(TestDigest, cls).setUpClass()
cls._activate_multi_company()
# clean messages
cls.env['mail.message'].search([
('subtype_id', '=', cls.env.ref('mail.mt_comment').id),
('message_type', 'in', ['comment', 'email']),
]).unlink()
cls._setup_messages()
# clean demo users so that we keep only the test users
cls.env['res.users'].search([('login', 'in', ['demo', 'portal'])]).action_archive()
# clean logs so that town down is activated
cls.env['res.users.log'].search([('create_uid', 'in', (cls.user_admin + cls.user_employee).ids)]).unlink()
cls.test_digest = cls.env['digest.digest'].create({
'kpi_mail_message_total': True,
'kpi_res_users_connected': True,
'name': "My Digest",
'kpi_mail_message_total': True
'periodicity': 'daily',
})
digest_user = digest.with_user(self.user_employee)
# subscribe a user so at least one mail gets sent
digest_user.action_subscribe()
self.assertTrue(
digest_user.is_subscribed,
"check the user was subscribed as action_subscribe will silently "
"ignore subs of non-employees"
)
# digest creates its mails in auto_delete mode so we need to capture
# the formatted body during the sending process
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(len(self._mails), 1, "a mail has been created for the digest")
body = self._mails[0]['body']
kpi_message_values = html.fromstring(body).xpath('//div[@data-field="kpi_mail_message_total"]//*[hasclass("kpi_value")]/text()')
self.assertEqual(
[t.strip() for t in kpi_message_values],
['3', '8', '15']
)
def _setup_messages(self):
@classmethod
def _setup_messages(cls):
""" Remove all existing messages, then create a bunch of them on random
partners with the correct types in correct time-bucket:
@@ -50,25 +52,199 @@ class TestDigest(mail_test.MailCommon):
based around weeks and months not days), for a total of 15 in the
previous month
"""
self.env['mail.message'].search([]).unlink()
now = fields.Datetime.now()
# regular employee can't necessarily access "private" addresses
partners = self.env['res.partner'].search([('type', '!=', 'private')])
partners = cls.env['res.partner'].search([('type', '!=', 'private')])
messages = cls.env['mail.message']
counter = itertools.count()
# pylint: disable=bad-whitespace
for count, (low, high) in [
(3, (0 * 24, 1 * 24)),
(5, (1 * 24, 7 * 24)),
(7, (7 * 24, 27 * 24)),
]:
now = fields.Datetime.now()
for count, (low, high) in [(3, (0 * 24, 1 * 24)),
(5, (1 * 24, 7 * 24)),
(7, (7 * 24, 27 * 24)),
]:
for _ in range(count):
create_date = now - relativedelta(hours=random.randint(low + 1, high - 1))
random.choice(partners).message_post(
messages += random.choice(partners).message_post(
author_id=cls.partner_admin.id,
body=f"Awesome Partner! ({next(counter)})",
email_from=cls.partner_admin.email_formatted,
message_type='comment',
subtype_xmlid='mail.mt_comment',
# adjust top and bottom by 1h to avoid overlapping with the
# range limit and dropping out of the digest's selection thing
create_date=create_date
create_date=create_date,
)
messages.flush()
@users('admin')
def test_digest_numbers(self):
digest = self.env['digest.digest'].browse(self.test_digest.ids)
digest._action_subscribe_users(self.user_employee)
# digest creates its mails in auto_delete mode so we need to capture
# the formatted body during the sending process
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(len(self._new_mails), 1, "A new mail.mail should have been created")
mail = self._new_mails[0]
# check mail.mail content
self.assertEqual(mail.author_id, self.partner_admin)
self.assertEqual(mail.email_from, self.company_admin.email_formatted)
self.assertEqual(mail.state, 'outgoing', 'Mail should use the queue')
kpi_message_values = html.fromstring(mail.body_html).xpath('//div[@data-field="kpi_mail_message_total"]//*[hasclass("kpi_value")]/text()')
self.assertEqual(
[t.strip() for t in kpi_message_values],
['3', '8', '15']
)
@users('admin')
def test_digest_subscribe(self):
digest_user = self.test_digest.with_user(self.user_employee)
self.assertFalse(digest_user.is_subscribed)
# subscribe a user so at least one mail gets sent
digest_user.action_subscribe()
self.assertTrue(
digest_user.is_subscribed,
"check the user was subscribed as action_subscribe will silently "
"ignore subs of non-employees"
)
@users('admin')
def test_digest_tone_down(self):
digest = self.env['digest.digest'].browse(self.test_digest.ids)
digest._action_subscribe_users(self.user_employee)
# initial data
self.assertEqual(digest.periodicity, 'daily')
# no logs for employee -> should tone down periodicity
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(digest.periodicity, 'weekly')
# no logs for employee -> should tone down periodicity
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(digest.periodicity, 'monthly')
# no logs for employee -> should tone down periodicity
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(digest.periodicity, 'quarterly')
@users('admin')
def test_digest_tone_down_wlogs(self):
digest = self.env['digest.digest'].browse(self.test_digest.ids)
digest._action_subscribe_users(self.user_employee)
# initial data
self.assertEqual(digest.periodicity, 'daily')
# logs for employee -> should not tone down
logs = self.env['res.users.log'].with_user(SUPERUSER_ID).create({'create_uid': self.user_employee.id})
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
logs.unlink()
logs = self.env['res.users.log'].with_user(SUPERUSER_ID).create({
'create_uid': self.user_employee.id,
'create_date': fields.Datetime.now() - relativedelta(days=20),
})
# logs for employee are more than 3 days old -> should tone down
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(digest.periodicity, 'weekly')
# logs for employee are more than 2 weeks old -> should tone down
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(digest.periodicity, 'monthly')
# logs for employee are less than 1 month old -> should not tone down
digest.flush()
with self.mock_mail_gateway():
digest.action_send()
self.assertEqual(digest.periodicity, 'monthly')
@tagged('-at_install', 'post_install')
class TestUnsubscribe(HttpCaseWithUserDemo):
def setUp(self):
super(TestUnsubscribe, self).setUp()
self.test_digest = self.env['digest.digest'].create({
'kpi_mail_message_total': True,
'kpi_res_users_connected': True,
'name': "My Digest",
'periodicity': 'daily',
'user_ids': self.user_demo.ids,
})
self.test_digest._action_subscribe_users(self.user_demo)
self.base_url = self.test_digest.get_base_url()
self.user_demo_unsubscribe_token = self.test_digest._get_unsubscribe_token(self.user_demo.id)
@users('demo')
def test_unsubscribe_classic(self):
self.assertIn(self.user_demo, self.test_digest.user_ids)
self.authenticate(self.env.user.login, self.env.user.login)
response = self._url_unsubscribe()
self.assertEqual(response.status_code, 200)
self.assertNotIn(self.user_demo, self.test_digest.user_ids)
@users('demo')
def test_unsubscribe_issues(self):
""" Test when not being member """
self.test_digest.write({'user_ids': [(3, self.user_demo.id)]})
self.assertNotIn(self.user_demo, self.test_digest.user_ids)
# unsubscribe
self.authenticate(self.env.user.login, self.env.user.login)
response = self._url_unsubscribe()
self.assertEqual(response.status_code, 200)
self.assertNotIn(self.user_demo, self.test_digest.user_ids)
def test_unsubscribe_token(self):
self.assertIn(self.user_demo, self.test_digest.user_ids)
self.authenticate(None, None)
response = self._url_unsubscribe(token=self.user_demo_unsubscribe_token, user_id=self.user_demo.id)
self.assertEqual(response.status_code, 200)
self.test_digest.invalidate_cache()
self.assertNotIn(self.user_demo, self.test_digest.user_ids)
def test_unsubscribe_public(self):
""" Check public users are redirected when trying to catch unsubscribe
route. """
self.authenticate(None, None)
response = self._url_unsubscribe()
self.assertEqual(response.status_code, 404)
def _url_unsubscribe(self, token=None, user_id=None):
url_params = {}
if token is not None:
url_params['token'] = token
if user_id is not None:
url_params['user_id'] = user_id
url = "%s/digest/%s/unsubscribe?%s" % (
self.base_url,
self.test_digest.id,
url_encode(url_params)
)
return self.url_open(url)
+20 -10
View File
@@ -931,10 +931,15 @@ class MassMailing(models.Model):
"""Send an email to the responsible of each finished mailing with the statistics."""
self.kpi_mail_required = False
mails_sudo = self.env['mail.mail'].sudo()
for mailing in self:
user = mailing.user_id
mailing = mailing.with_context(lang=user.lang or self._context.get('lang'))
if mailing.user_id:
mailing = mailing.with_user(mailing.user_id).with_context(
lang=mailing.user_id.lang or self._context.get('lang')
)
mailing_type = mailing._get_pretty_mailing_type()
mail_user = mailing.user_id or self.env.user
mail_company = mail_user.company_id
link_trackers = self.env['link.tracker'].search(
[('mass_mailing_id', '=', mailing.id)]
@@ -952,8 +957,8 @@ class MassMailing(models.Model):
'digest.digest_mail_main',
{
'body': tools.html_sanitize(link_trackers_body),
'company': user.company_id,
'user': user,
'company': mail_company,
'user': mail_user,
'display_mobile_banner': True,
** mailing._prepare_statistics_email_values()
},
@@ -965,17 +970,20 @@ class MassMailing(models.Model):
)
mail_values = {
'auto_delete': True,
'author_id': mail_user.partner_id.id,
'email_from': mail_user.email_formatted,
'email_to': mail_user.email_formatted,
'body_html': full_mail,
'reply_to': mail_company.email_formatted or mail_user.email_formatted,
'state': 'outgoing',
'subject': _('24H Stats of %(mailing_type)s "%(mailing_name)s"',
mailing_type=mailing._get_pretty_mailing_type(),
mailing_name=mailing.subject
),
'email_from': user.email_formatted,
'email_to': user.email_formatted,
'body_html': full_mail,
'auto_delete': True,
}
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send(raise_exception=False)
mails_sudo += self.env['mail.mail'].sudo().create(mail_values)
return mails_sudo
def _prepare_statistics_email_values(self):
"""Return some statistics that will be displayed in the mailing statistics email.
@@ -1005,6 +1013,7 @@ class MassMailing(models.Model):
'col_subtitle': _('REPLIED (%i)', self.replied),
},
'kpi_action': None,
'kpi_name': self.mailing_type,
}
random_tip = self.env['digest.tip'].search(
@@ -1037,6 +1046,7 @@ class MassMailing(models.Model):
'kpi_col1': {},
'kpi_col2': {},
'kpi_col3': {},
'kpi_name': 'trace',
},
],
'tips': [random_tip] if random_tip else False,
+4 -1
View File
@@ -131,8 +131,11 @@ class MailingTrace(models.Model):
return traces
def set_opened(self, domain=None):
""" Reply / Open are a bit shared in various processes: reply implies
open, click implies open. Let us avoid status override by skipping traces
that are not already opened or replied. """
traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
traces.write({'trace_status': 'open', 'open_datetime': fields.Datetime.now()})
traces.filtered(lambda t: t.trace_status not in ('open', 'reply')).write({'trace_status': 'open', 'open_datetime': fields.Datetime.now()})
return traces
def set_clicked(self, domain=None):
@@ -40,4 +40,5 @@ class MassMailing(models.Model):
'value': tools.format_decimalized_number(self.crm_lead_count, decimal=0),
'col_subtitle': _('LEADS'),
}
values['kpi_data'][1]['kpi_name'] = 'lead'
return values
@@ -87,4 +87,5 @@ class MassMailing(models.Model):
'value': formated_amount,
'col_subtitle': _('INVOICED'),
}
values['kpi_data'][1]['kpi_name'] = 'sale'
return values
@@ -291,6 +291,7 @@ class Mailing(models.Model):
'col_subtitle': _('BOUNCED (%i)', self.bounced),
},
'kpi_action': None,
'kpi_name': self.mailing_type,
}
return values
+3 -1
View File
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
import re
import werkzeug
@@ -147,7 +149,7 @@ class MassSMSCase(SMSCase):
self.env['link.tracker.click'].sudo().add_click(
code,
ip='100.200.300.400',
ip='100.200.300.%3f' % random.random(),
country_code='BE',
mailing_trace_id=trace_id
)
+1
View File
@@ -4,6 +4,7 @@ from . import common
from . import test_odoobot
from . import test_phone_blacklist
from . import test_mass_mailing
from . import test_mass_mailing_statistics
from . import test_mass_sms
from . import test_sms_composer
from . import test_sms_management
+3 -3
View File
@@ -18,8 +18,8 @@ class TestMailFullCommon(TestMassMailCommon, MassSMSCommon):
'subject': 'Xmas SMS for {object.name}',
'mailing_model_id': cls.env['ir.model']._get('mail.test.sms').id,
'mailing_type': 'sms',
'mailing_domain': '%s' % repr([('name', 'ilike', 'SMSTest')]),
'body_plaintext': 'Dear {{ object.display_name }} this is a mass SMS with two links http://www.odoo.com/smstest and http://www.odoo.com/smstest/{{ object.id }}',
'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]),
'body_plaintext': 'Dear {{object.display_name}} this is a mass SMS with two links http://www.odoo.com/smstest and http://www.odoo.com/smstest/{{object.id}}',
'sms_force_send': True,
'sms_allow_unsubscribe': True,
})
@@ -34,7 +34,7 @@ class TestMailFullCommon(TestMassMailCommon, MassSMSCommon):
vals_list = []
for idx in range(count):
vals = {
'name': 'SMSTestRecord_%02d' % idx,
'name': 'MassSMSTestRecord_%02d' % idx,
phone_field: '045600%02d%02d' % (idx, idx)
}
if partners:
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import html
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mail_full.tests.test_mass_sms import TestMassSMSCommon
from odoo.tests.common import users
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged('digest', 'mass_mailing')
class TestMailingStatistics(TestMassSMSCommon):
@classmethod
def setUpClass(cls):
super(TestMailingStatistics, cls).setUpClass()
cls.user_marketing_2 = mail_new_test_user(
cls.env,
groups='base.group_user,base.group_partner_manager,mass_mailing.group_mass_mailing_user',
login='user_marketing_2',
name='Marie Marketing',
signature='--\nMarie'
)
@users('user_marketing')
@mute_logger('odoo.addons.mass_mailing_sms.models.mailing_mailing', 'odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread')
def test_mailing_statistics_sms(self):
mailing = self.env['mailing.mailing'].browse(self.mailing_sms.ids)
target_records = self.env['mail.test.sms'].browse(self.records.ids)
mailing.write({'mailing_domain': [('id', 'in', target_records.ids)], 'user_id': self.user_marketing_2.id})
mailing.action_put_in_queue()
with self.mockSMSGateway():
mailing.action_send_sms()
# simulate some replies and clicks
self.gateway_sms_click(mailing, target_records[0])
self.gateway_sms_click(mailing, target_records[2])
self.gateway_sms_click(mailing, target_records[3])
# check mailing statistics
self.assertEqual(mailing.clicked, 3)
self.assertEqual(mailing.delivered, 10)
self.assertEqual(mailing.opened, 3)
self.assertEqual(mailing.opened_ratio, 30)
self.assertEqual(mailing.sent, 10)
with self.mock_mail_gateway(mail_unlink_sent=True):
mailing._action_send_statistics()
self.assertEqual(len(self._new_mails), 1, "Mailing: a mail should have been created for statistics")
mail = self._new_mails[0]
# test email values
self.assertEqual(mail.author_id, self.user_marketing_2.partner_id)
self.assertEqual(mail.email_from, self.user_marketing_2.email_formatted)
self.assertEqual(mail.email_to, self.user_marketing_2.email_formatted)
self.assertEqual(mail.reply_to, self.company_admin.partner_id.email_formatted)
self.assertEqual(mail.state, 'outgoing')
# test body content: KPIs
body_html = html.fromstring(mail.body_html)
kpi_values = body_html.xpath('//div[@data-field="sms"]//*[hasclass("kpi_value")]/text()')
self.assertEqual(
[t.strip().strip('%') for t in kpi_values],
['100', str(mailing.opened_ratio), str(mailing.replied_ratio)]
)
# test body content: clicks (a bit hackish but hey we are in stable)
kpi_click_values = body_html.xpath('//div[hasclass("global_layout")]/table//tr[contains(@style,"color: #888888")]/td[contains(@style,"width: 30%")]/text()')
first_link_value = int(kpi_click_values[0].strip().split()[1].strip('()'))
self.assertEqual(first_link_value, mailing.clicked)
+31 -34
View File
@@ -51,16 +51,6 @@ class TestMassSMSCommon(TestMailFullCommon):
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,
})
@tagged('mass_mailing')
class TestMassSMSInternals(TestMassSMSCommon):
@@ -114,6 +104,7 @@ class TestMassSMSInternals(TestMassSMSCommon):
mailing, self.records,
)
@users('user_marketing')
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({
@@ -141,7 +132,7 @@ class TestMassSMSInternals(TestMassSMSCommon):
'customer_id': False,
'phone_nbr': '0456110011',
})
self.env['phone.blacklist'].create({'number': '0456110011'})
self.env['phone.blacklist'].sudo().create({'number': '0456110011'})
# 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({
@@ -156,81 +147,87 @@ class TestMassSMSInternals(TestMassSMSCommon):
})
records_numbers = self.records_numbers + ['+32456999999']
mailing = self.env['mailing.mailing'].browse(self.mailing_sms.ids)
mailing.write({'sms_force_send': False}) # force outgoing sms, not sent
with self.with_user('user_marketing'):
with self.mockSMSGateway():
self.mailing.action_send_sms()
mailing.action_send_sms()
self.assertSMSTraces(
[{'partner': record.customer_id, 'number': records_numbers[i],
'content': 'Dear %s this is a mass SMS.' % record.display_name}
'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,
mailing, self.records | new_record_1,
)
# duplicates
self.assertSMSTraces(
[{'partner': new_record_2.customer_id, 'number': self.records_numbers[0],
'content': 'Dear %s this is a mass SMS.' % new_record_2.display_name, 'trace_status': 'cancel',
'content': 'Dear %s this is a mass SMS' % new_record_2.display_name, 'trace_status': 'cancel',
'failure_type': 'sms_duplicate'}],
self.mailing, new_record_2,
mailing, new_record_2,
)
# blacklist
self.assertSMSTraces(
[{'partner': self.env['res.partner'], 'number': phone_validation.phone_format(bl_record_1.phone_nbr, 'BE', '32', force_format='E164'),
'content': 'Dear %s this is a mass SMS.' % bl_record_1.display_name, 'trace_status': 'cancel',
'content': 'Dear %s this is a mass SMS' % bl_record_1.display_name, 'trace_status': 'cancel',
'failure_type': 'sms_blacklist'}],
self.mailing, bl_record_1,
mailing, bl_record_1,
)
# missing number
self.assertSMSTraces(
[{'partner': self.env['res.partner'], 'number': False,
'content': 'Dear %s this is a mass SMS.' % void_record.display_name, 'trace_status': 'cancel',
'content': 'Dear %s this is a mass SMS' % void_record.display_name, 'trace_status': 'cancel',
'failure_type': 'sms_number_missing'}],
self.mailing, void_record,
mailing, void_record,
)
# wrong values
self.assertSMSTraces(
[{'partner': self.env['res.partner'], 'number': record.phone_nbr,
'content': 'Dear %s this is a mass SMS.' % record.display_name, 'trace_status': 'cancel',
'content': 'Dear %s this is a mass SMS' % record.display_name, 'trace_status': 'cancel',
'failure_type': 'sms_number_format'}
for record in falsy_record_1 + falsy_record_2],
self.mailing, falsy_record_1 + falsy_record_2,
mailing, falsy_record_1 + falsy_record_2,
)
@users('user_marketing')
def test_mass_sms_internals_done_ids(self):
mailing = self.env['mailing.mailing'].browse(self.mailing_sms.ids)
mailing.write({'sms_force_send': False}) # check with outgoing traces, not already sent
with self.with_user('user_marketing'):
with self.mockSMSGateway():
self.mailing.action_send_sms(res_ids=self.records[:5].ids)
mailing.action_send_sms(res_ids=self.records[:5].ids)
traces = self.env['mailing.trace'].search([('mass_mailing_id', 'in', self.mailing.ids)])
traces = self.env['mailing.trace'].search([('mass_mailing_id', 'in', mailing.ids)])
self.assertEqual(len(traces), 5)
# new traces generated
self.assertSMSTraces(
[{'partner': record.customer_id, 'number': self.records_numbers[i],
'content': 'Dear %s this is a mass SMS.' % record.display_name}
'content': 'Dear %s this is a mass SMS' % record.display_name}
for i, record in enumerate(self.records[:5])],
self.mailing, self.records[:5],
mailing, self.records[:5],
)
with self.with_user('user_marketing'):
with self.mockSMSGateway():
self.mailing.action_send_sms(res_ids=self.records.ids)
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.assertSMSTraces(
[{'partner': record.customer_id, 'number': self.records_numbers[i],
'content': 'Dear %s this is a mass SMS.' % record.display_name, 'trace_status': 'cancel',
'content': 'Dear %s this is a mass SMS' % record.display_name, 'trace_status': 'cancel',
'failure_type': 'sms_duplicate'}
for i, record in enumerate(self.records[:5])],
self.mailing, self.records[:5],
mailing, self.records[:5],
)
# new traces generated
self.assertSMSTraces(
[{'partner': record.customer_id, 'number': self.records_numbers[i+5],
'content': 'Dear %s this is a mass SMS.' % record.display_name}
'content': 'Dear %s this is a mass SMS' % record.display_name}
for i, record in enumerate(self.records[5:])],
self.mailing, self.records[5:],
mailing, self.records[5:],
)
@mute_logger('odoo.addons.mail.models.mail_render_mixin')
@@ -268,7 +265,7 @@ class TestMassSMS(TestMassSMSCommon):
@users('user_marketing')
def test_mass_sms_links(self):
mailing = self.env['mailing.mailing'].browse(self.mailing.ids)
mailing = self.env['mailing.mailing'].browse(self.mailing_sms.ids)
mailing.write({
'body_plaintext': 'Dear {{ object.display_name }} this is a mass SMS with two links http://www.odoo.com/smstest and http://www.odoo.com/smstest/{{ object.name }}',
'sms_template_id': False,
@@ -305,7 +302,7 @@ class TestMassSMS(TestMassSMSCommon):
})
records = self.env['mail.test.sms.partner'].create([
{'name': 'SMSTest on %s' % partner.name,
{'name': 'MassSMSTest on %s' % partner.name,
'customer_id': partner.id,
} for partner in self.partners
])
@@ -365,7 +362,7 @@ class TestMassSMS(TestMassSMSCommon):
})
self.env['mail.test.sms.partner.2many'].create([
{'name': 'SMSTest on %s' % partner.name,
{'name': 'MassSMSTest on %s' % partner.name,
'customer_ids': [(4, partner.id)],
} for partner in self.partners
])
@@ -7,5 +7,6 @@ from . import test_blacklist_mixin
from . import test_link_tracker
from . import test_mailing
from . import test_mailing_server
from . import test_mailing_statistics
from . import test_mailing_test
from . import test_performance
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import html
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mass_mailing.data.mail_test_data import MAIL_TEMPLATE
from odoo.addons.test_mass_mailing.tests.common import TestMassMailCommon
from odoo.tests.common import users
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged('digest')
class TestMailingStatistics(TestMassMailCommon):
@classmethod
def setUpClass(cls):
super(TestMailingStatistics, cls).setUpClass()
cls.user_marketing_2 = mail_new_test_user(
cls.env,
groups='base.group_user,base.group_partner_manager,mass_mailing.group_mass_mailing_user',
login='user_marketing_2',
name='Marie Marketing',
signature='--\nMarie'
)
@users('user_marketing')
@mute_logger('odoo.addons.mass_mailing.models.mailing', 'odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread')
def test_mailing_statistics(self):
target_records = self._create_mailing_test_records(model='mailing.test.blacklist', count=10)
mailing = self.env['mailing.mailing'].browse(self.mailing_bl.ids)
mailing.write({'mailing_domain': [('id', 'in', target_records.ids)], 'user_id': self.user_marketing_2.id})
mailing.action_put_in_queue()
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing.action_send_mail()
# simulate some replies and clicks
self.gateway_mail_reply_wrecord(MAIL_TEMPLATE, target_records[0], use_in_reply_to=True)
self.gateway_mail_reply_wrecord(MAIL_TEMPLATE, target_records[1], use_in_reply_to=True)
self.gateway_mail_reply_wrecord(MAIL_TEMPLATE, target_records[2], use_in_reply_to=True)
self.gateway_mail_click(mailing, target_records[0], 'https://www.odoo.be')
self.gateway_mail_click(mailing, target_records[2], 'https://www.odoo.be')
self.gateway_mail_click(mailing, target_records[3], 'https://www.odoo.be')
# check mailing statistics
self.assertEqual(mailing.clicked, 3)
self.assertEqual(mailing.delivered, 10)
self.assertEqual(mailing.opened, 4)
self.assertEqual(mailing.opened_ratio, 40)
self.assertEqual(mailing.replied, 3)
self.assertEqual(mailing.replied_ratio, 30)
self.assertEqual(mailing.sent, 10)
with self.mock_mail_gateway(mail_unlink_sent=True):
mailing._action_send_statistics()
self.assertEqual(len(self._new_mails), 1, "Mailing: a mail should have been created for statistics")
mail = self._new_mails[0]
# test email values
self.assertEqual(mail.author_id, self.user_marketing_2.partner_id)
self.assertEqual(mail.email_from, self.user_marketing_2.email_formatted)
self.assertEqual(mail.email_to, self.user_marketing_2.email_formatted)
self.assertEqual(mail.reply_to, self.company_admin.partner_id.email_formatted)
self.assertEqual(mail.state, 'outgoing')
# test body content: KPIs
body_html = html.fromstring(mail.body_html)
kpi_values = body_html.xpath('//div[@data-field="mail"]//*[hasclass("kpi_value")]/text()')
self.assertEqual(
[t.strip().strip('%') for t in kpi_values],
['100', str(mailing.opened_ratio), str(mailing.replied_ratio)]
)
# test body content: clicks (a bit hackish but hey we are in stable)
kpi_click_values = body_html.xpath('//div[hasclass("global_layout")]/table//tr[contains(@style,"color: #888888")]/td[contains(@style,"width: 30%")]/text()')
first_link_value = int(kpi_click_values[0].strip().split()[1].strip('()'))
self.assertEqual(first_link_value, mailing.clicked)
@users('user_marketing')
@mute_logger('odoo.addons.mass_mailing.models.mailing', 'odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread')
def test_mailing_statistics_wo_user(self):
target_records = self._create_mailing_test_records(model='mailing.test.blacklist', count=10)
mailing = self.env['mailing.mailing'].browse(self.mailing_bl.ids)
mailing.write({'mailing_domain': [('id', 'in', target_records.ids)], 'user_id': False})
mailing.action_put_in_queue()
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing.action_send_mail()
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing._action_send_statistics()
self.assertEqual(len(self._new_mails), 1, "Mailing: a mail should have been created for statistics")
mail = self._new_mails[0]
# test email values
self.assertEqual(mail.author_id, self.user_marketing.partner_id)
self.assertEqual(mail.email_from, self.user_marketing.email_formatted)
self.assertEqual(mail.email_to, self.user_marketing.email_formatted)
self.assertEqual(mail.reply_to, self.company_admin.partner_id.email_formatted)
self.assertEqual(mail.state, 'outgoing')