19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:39 +01:00
parent 5df8c07b59
commit daa394e8b0
2114 changed files with 564841 additions and 299642 deletions

View file

@ -5,3 +5,6 @@ from . import common
from . import test_mailing_internals
from . import test_mailing_retry
from . import test_mailing_sms_ab_testing
from . import test_mailing_ui
from . import test_mailing_list
from . import test_mailing_controllers

View file

@ -5,7 +5,7 @@ import random
import re
import werkzeug
from odoo import tools
from odoo.tools import mail
from odoo.addons.link_tracker.tests.common import MockLinkTracker
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.addons.sms.tests.common import SMSCase, SMSCommon
@ -22,7 +22,7 @@ class MassSMSCase(SMSCase, MockLinkTracker):
return self.assertSMSTraces(recipients_info, mailing, records, check_sms=check_sms)
def assertSMSTraces(self, recipients_info, mailing, records,
check_sms=True, sent_unlink=False,
check_sms=True, is_cancel_not_sent=True, sent_unlink=False,
sms_links_info=None):
""" Check content of traces. Traces are fetched based on a given mailing
and records. Their content is compared to recipients_info structure that
@ -34,7 +34,8 @@ class MassSMSCase(SMSCase, MockLinkTracker):
# TRACE
'partner': res.partner record (may be empty),
'number': number used for notification (may be empty, computed based on partner),
'trace_status': outgoing / sent / cancel / bounce / error / opened (sent by default),
'trace_status': outgoing / process / pending / sent / cancel / bounce / error / opened
(sent by default),
'record: linked record,
# SMS.SMS
'content': optional: if set, check content of sent SMS;
@ -46,12 +47,16 @@ class MassSMSCase(SMSCase, MockLinkTracker):
generated;
:param records: records given to mailing that generated traces. It is
used notably to find traces using their IDs;
:param check_sms: if set, check sms.sms records that should be linked to traces;
:param check_sms: if set, check sms.sms records that should be linked to traces
unless not sent (trace_status == 'cancel');
:param is_cancel_not_sent: if True, also check that no mail.message
related to "cancel trace" have been created and disable check_sms for those.
:param sent_unlink: it True, sent sms.sms are deleted and we check gateway
output result instead of actual sms.sms records;
:param sms_links_info: if given, should follow order of ``recipients_info``
and give details about links. See ``assertLinkShortenedHtml`` helper for
more details about content to give;
more details about content to give
Not tested for sms with trace status == 'cancel' if is_cancel_not_sent;
]
"""
# map trace state to sms state
@ -61,48 +66,102 @@ class MassSMSCase(SMSCase, MockLinkTracker):
'error': 'error',
'cancel': 'canceled',
'bounce': 'error',
'process': 'process',
'pending': 'pending',
}
traces = self.env['mailing.trace'].search([
('mass_mailing_id', 'in', mailing.ids),
('res_id', 'in', records.ids)
])
debug_info = '\n'.join(
f'Trace: to {t.sms_number} - state {t.trace_status} - res_id {t.res_id}'
for t in traces
)
traces_info = []
for trace in traces:
record = records.filtered(lambda r: r.id == trace.res_id)
if record:
traces_info.append(
f'Trace: doc {trace.res_id} on {trace.sms_number} - status {trace.trace_status} (rec {record.id})'
)
else:
traces_info.append(
f'Trace: doc {trace.res_id} on {trace.sms_number} - status {trace.trace_status}'
)
debug_info = '\n'.join(traces_info)
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))
self.assertEqual(
{s.res_id for s in traces}, set(records.ids),
f'Should find one trace / record. Found\n{debug_info}'
)
# check each trace
if not sms_links_info:
sms_links_info = [None] * len(recipients_info)
for recipient_info, link_info, record in zip(recipients_info, sms_links_info, records):
# check input
invalid = set(recipient_info.keys()) - {
'content',
'record',
# recipient
'number',
'partner',
# trace info
'failure_type',
'trace_status',
# check control
'check_sms',
}
if invalid:
raise AssertionError(f"assertSMSTraces: invalid input {invalid}")
# recipients
partner = recipient_info.get('partner', self.env['res.partner'])
number = recipient_info.get('number')
status = recipient_info.get('trace_status', 'outgoing')
content = recipient_info.get('content', None)
if number is None and partner:
number = partner._sms_get_recipients_info()[partner.id]['sanitized']
# trace
status = recipient_info.get('trace_status', 'outgoing')
failure_type = recipient_info['failure_type'] if status in ('error', 'cancel', 'bounce') else None
# content
content = recipient_info.get('content', None)
record = record or recipient_info.get('record')
# checks
recipient_check_sms = recipient_info.get('check_sms', check_sms)
trace = traces.filtered(
lambda t: t.sms_number == number and t.trace_status == status and (t.res_id == record.id if record else True)
)
self.assertTrue(len(trace) == 1,
'SMS: found %s notification for number %s, (status: %s) (1 expected)' % (len(trace), number, status))
self.assertTrue(bool(trace.sms_sms_id_int))
self.assertTrue(
len(trace) == 1,
'SMS: found %s notification for number %s (res_id: %s) (status: %s) (1 expected)\n--MOCKED DATA\n%s' % (
len(trace), number, record.id,
status, debug_info
)
)
sms_not_created = is_cancel_not_sent and trace.trace_status == 'cancel'
self.assertTrue(sms_not_created or bool(trace.sms_id_int))
if sms_not_created:
self.assertFalse(trace.sms_id_int)
self.assertFalse(self.env['mail.message'].sudo().search(
[('model', '=', record._name), ('res_id', '=', record.id),
('id', 'in', self._new_sms.mail_message_id.ids)]))
if check_sms:
if status == 'sent':
if recipient_check_sms and not sms_not_created:
if status in {'process', 'pending', 'sent'}:
if sent_unlink:
self.assertSMSIapSent([number], content=content)
else:
self.assertSMS(partner, number, 'sent', content=content)
self.assertSMS(partner, number, status, content=content)
elif status in state_mapping:
sms_state = state_mapping[status]
failure_type = recipient_info['failure_type'] if status in ('error', 'cancel', 'bounce') else None
self.assertSMS(partner, number, sms_state, failure_type=failure_type, content=content)
else:
raise NotImplementedError()
if link_info:
if link_info and not sms_not_created:
# shortened links are directly included in sms.sms record as well as
# in sent sms (not like mails who are post-processed)
sms_sent = self._find_sms_sent(partner, number)
@ -123,30 +182,62 @@ class MassSMSCase(SMSCase, MockLinkTracker):
(url, is_shortened),
link_params=link_params,
)
return traces
# ------------------------------------------------------------
# GATEWAY TOOLS
# ------------------------------------------------------------
def gateway_sms_click(self, mailing, record):
def gateway_sms_bounce(self, mailing, records, error_code='invalid_destination'):
""" Bounce SMS through sms/status controller """
traces = mailing.mailing_trace_ids.filtered(lambda t: t.model == records._name and t.res_id in records.ids)
statuses = [{
'sms_status': error_code,
'uuids': traces.sms_tracker_ids.mapped('sms_uuid'),
}]
with self.with_user(self.user_admin.login):
self._make_webhook_jsonrpc_request(statuses)
def gateway_sms_click(self, mailing, record, use_sent_sms=True):
""" Simulate a click on a sent SMS. Usage: giving a partner and/or
a number, find an SMS sent to him, find shortened links in its body
and call add_click to simulate a click. """
trace = mailing.mailing_trace_ids.filtered(lambda t: t.model == record._name and t.res_id == record.id)
sms_sent = self._find_sms_sent(self.env['res.partner'], trace.sms_number)
self.assertTrue(bool(sms_sent))
return self.gateway_sms_sent_click(sms_sent)
if use_sent_sms:
sms_sent = self._find_sms_sent(self.env['res.partner'], trace.sms_number)
self.assertTrue(bool(sms_sent))
return self.gateway_sms_sent_click(sms_sent)
sms_sms = self._find_sms_sms(record._mail_get_partners()[record.id], trace.sms_number, 'outgoing')
self.assertTrue(bool(sms_sms))
with self.with_user(self.user_admin.login):
return self.gateway_sms_sms_click(sms_sms)
def gateway_sms_delivered(self, mailing, records):
""" Simulate a delivery report received for a sent SMS."""
traces = mailing.mailing_trace_ids.filtered(lambda t: t.model == records._name and t.res_id in records.ids)
with self.with_user(self.user_admin.login):
statuses = [{
'sms_status': 'delivered',
'uuids': traces.with_user(self.user_admin).sms_tracker_ids.mapped('sms_uuid'),
}]
self._make_webhook_jsonrpc_request(statuses)
def gateway_sms_sent_click(self, sms_sent):
return self._gateway_sms_click(sms_sent['body'])
def gateway_sms_sms_click(self, sms_sms):
return self._gateway_sms_click(sms_sms.body)
def _gateway_sms_click(self, body):
""" When clicking on a link in a SMS we actually don't have any
easy information in body, only body. We currently click on all found
shortened links. """
for url in re.findall(tools.TEXT_URL_REGEX, sms_sent['body']):
for url in re.findall(mail.TEXT_URL_REGEX, body):
if '/r/' in url: # shortened link, like 'http://localhost:8069/r/LBG/s/53'
parsed_url = werkzeug.urls.url_parse(url)
path_items = parsed_url.path.split('/')
code, sms_sms_id = path_items[2], int(path_items[4])
trace_id = self.env['mailing.trace'].sudo().search([('sms_sms_id_int', '=', sms_sms_id)]).id
code, sms_id_int = path_items[2], int(path_items[4])
trace_id = self.env['mailing.trace'].sudo().search([('sms_id_int', '=', sms_id_int)]).id
self.env['link.tracker.click'].sudo().add_click(
code,

View file

@ -0,0 +1,38 @@
from odoo.tests.common import users
from odoo.tests import tagged
from odoo.tools.urls import urljoin as url_join
from odoo.addons.mass_mailing_sms.tests.common import MassSMSCommon
@tagged('mailing_portal', 'post_install', '-at_install')
class TestMailingListSms(MassSMSCommon):
@users('user_marketing')
def test_controller_unsubscribe(self):
""" Test unsubscribe controller from a phone number, including phone
formatting and validation """
partner = self.env['res.partner'].create({
'name': 'Test Partner',
'phone': '+91 1234657890',
})
mailing = self.env['mailing.mailing'].create({
'name': 'TestMailing',
'body_plaintext': 'Coucou hibou',
'mailing_type': 'sms',
'mailing_model_id': self.env['ir.model']._get_id('res.partner'),
'mailing_domain': [('id', '=', partner.id)],
'subject': 'Test',
'sms_allow_unsubscribe': True,
})
mailing.action_send_sms()
trace = mailing.mailing_trace_ids.filtered(lambda t: t.res_id == partner.id)
self.assertTrue(trace, 'Trace not found for the partner')
self.assertEqual(trace.sms_number, '+911234657890')
unsubscribe_url = url_join(mailing.get_base_url(), f'/sms/{mailing.id}/unsubscribe/{trace.sms_code}')
response = self.url_open(url=unsubscribe_url, data={'sms_number': trace.sms_number}, method='GET')
self.assertEqual(response.status_code, 200)
self.assertTrue(partner.phone_blacklisted, 'Partner not unsubscribed')

View file

@ -35,8 +35,7 @@ class TestMassMailValues(MassSMSCommon):
self.assertEqual(mailing.medium_id, self.env.ref('mass_mailing_sms.utm_medium_sms'))
self.assertEqual(mailing.mailing_model_name, 'res.partner')
self.assertEqual(mailing.mailing_model_real, 'res.partner')
# default for partner: remove blacklisted
self.assertEqual(literal_eval(mailing.mailing_domain), [('phone_sanitized_blacklisted', '=', False)])
self.assertEqual(literal_eval(mailing.mailing_domain), [])
# update template -> update body
mailing.write({'sms_template_id': self.sms_template_partner.id})
self.assertEqual(mailing.body_plaintext, self.sms_template_partner.body)
@ -81,7 +80,7 @@ class TestMassMailValues(MassSMSCommon):
expected = {
'link': f'{base_url}/r/xxx{"x" if link_trackers else ""}/s/xxxxx',
'unsubscribe': f"\nSTOP SMS : {base_url}/sms/{'x' * len(str(mailing.id))}/{'x' * self.env['mailing.trace'].CODE_SIZE}",
'unsubscribe': f"\nSTOP SMS: {base_url}/sms/{'x' * len(str(mailing.id))}/{'x' * self.env['mailing.trace'].CODE_SIZE}",
}
self.assertDictEqual(mailing.get_sms_link_replacements_placeholders(), expected)

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mass_mailing_sms.tests.common import MassSMSCommon
from odoo.tests import Form, users
class TestMailingListSms(MassSMSCommon):
@classmethod
def setUpClass(cls):
super(TestMailingListSms, cls).setUpClass()
cls._create_mailing_list()
@users('user_marketing')
def test_mailing_list_action_send_sms(self):
sms_ctx = self.mailing_list_1.action_send_mailing_sms().get('context', {})
form = Form(self.env['mailing.mailing'].with_context(sms_ctx))
form.sms_subject = 'Test SMS'
form.body_plaintext = 'Test sms body'
sms = form.save()
# Check that mailing model and mailing list are set properly
self.assertEqual(
sms.mailing_model_id, self.env['ir.model']._get('mailing.list'),
'Should have correct mailing model set')
self.assertEqual(sms.contact_list_ids, self.mailing_list_1, 'Should have correct mailing list set')
self.assertEqual(sms.mailing_type, 'sms', 'Should have correct mailing_type')

View file

@ -0,0 +1,43 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import HttpCase, tagged, users
@tagged('post_install', '-at_install', 'mail_activity')
class TestMailingUi(HttpCase):
@users('admin')
def test_tour_mailing_activities_split(self):
""" Activities linked to mailing.mailing records can appear either in the
'Email Marketing', either in the 'SMS Marketing' category, depending on
the value of the field mailing_type of the record it is linked to. This
test ensures that:
- activities linked to records with mailing_type set to mail are listed
in the 'Email Marketing' category
- activities linked to records with mailing_type set to sms are listed
in the 'SMS Marketing' category
"""
user_admin = self.env.ref('base.user_admin')
user_admin.write({
'email': 'mitchell.admin@example.com',
})
sms_rec, email_rec = self.env['mailing.mailing'].create([
{
'body_plaintext': 'Some sms spam',
'mailing_type': 'sms',
'name': 'SMS record with an activity',
'subject': 'New SMS!',
}, {
'body_html': '<p>Some email spam</p>',
'mailing_type': 'mail',
'name': 'Email record with an activity',
'subject': 'New Email!',
}
])
sms_rec.activity_schedule(act_type_xmlid='mail.mail_activity_data_todo', user_id=user_admin.id)
email_rec.activity_schedule(act_type_xmlid='mail.mail_activity_data_todo', user_id=user_admin.id)
# Ensure that both activities appear in the systray and that clicking on
# one activity opens a view where the other activity isn't listed
self.start_tour("/odoo", 'mailing_activities_split', login="admin")