Files
enterprise-addons/marketing_automation/tests/common.py
T

507 lines
24 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from contextlib import contextmanager
from datetime import timedelta
from freezegun import freeze_time
from unittest.mock import patch
from odoo import fields
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mass_mailing.tests.common import MassMailCase, MassMailCommon
_logger = logging.getLogger(__name__)
class MarketingAutomationCase(MassMailCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# store MA crons on cls, always handy to have them available without ref
cls.cron_ma_sync_participants = cls.env.ref('marketing_automation.ir_cron_campaign_sync_participants')
cls.cron_ma_execute_activities = cls.env.ref('marketing_automation.ir_cron_campaign_execute_activities')
cls.date_reference = fields.Datetime.from_string("2024-07-15 10:30:00")
def setUp(self):
super().setUp()
crons = self.cron_ma_sync_participants + self.cron_ma_execute_activities
self.env['ir.cron.progress'].search([('cron_id', 'in', crons.ids)]).unlink()
self.cron_last_call = self.date_reference - timedelta(hours=11)
crons.write({
'failure_count': 0,
'first_failure_date': False,
'interval_number': 12,
'interval_type': 'hours',
'lastcall': self.cron_last_call,
'nextcall': self.date_reference + timedelta(hours=1),
})
self.cron_ma_execute_activities.write({'interval_number': 2})
@contextmanager
def mock_datetime_and_now(self, mock_dt):
""" Used when synchronization date (using env.cr.now()) is important
in addition to standard datetime mocks. Used mainly to detect sync
issues. """
with freeze_time(mock_dt) as frozen_datetime_mock, \
patch.object(self.env.cr, 'now', lambda: mock_dt):
self.frozen_datetime_mock = frozen_datetime_mock
yield
# ------------------------------------------------------------
# TOOLS AND ASSERTS
# ------------------------------------------------------------
def assertMarketAutoTraces(self, participants_info, activity, strict=True, canceled_res_ids=None):
""" Check content of traces.
:param participants_info: [{
# participants
'participants': participants record_set, # optional: check coherency of expected participants
'participants_state': string, # optional: check linked participants state
# marketing trace
'fields_values': dict, # optional fields values to check on marketing.trace
'status': status, # marketing trace status (processed, ...) for all records
# record info
'records': records, # records going through this activity
'records_missing': boolean, # records have been unlinked due to various reasons -> do not access
# mailing/sms trace
'check_mail': boolean, # whether to check mail
'mailing_trace_values': value to check # propagated to type specific asserts (mail or SMS)
'trace_content': content of mail/sms # content of sent mail / sms / whatsapp
'trace_email': email logged on trace # may differ from 'email_normalized'
'trace_email_to_mail': email logged on mail # for assertMailMail
'trace_email_to_recipients': email # for assertSentEmail
'trace_failure_reason': failure_reason of trace, # to check message update in case of failure
'trace_failure_type': failure_type of trace # to check status update in case of failure
'trace_status': status of mailing trace, # if not set: check there is no mailing trace
'email_values': outgoing email check # for assertSentEmail
'mail_values': mail.mail check # for assertMailMail
# sms specific (see assertSMSTraces)
'check_sms': boolean, # whether to check SMS
'sms_values': dict, # sms.sms values
'sent_unlink': boolean, # whether sms are unlinked, hence checking IAP mock data directly
'trace_sms_number': char, # 'number' check, fallback on phone_sanitized
# WA specific
'wa_from_mock',
'wa_msg_values',
# mailing/sms/WA trace, record-specific information
'records_to_email_to_mail': {rec.id: email}, # override trace_email_to_mail (mail)
'records_to_email_to_recipients': {rec.id: email}, # override trace_email_to_recipients (mail)
'records_to_trace_email': {rec.id: email}, # override trace_email (mail)
'records_to_trace_status': {rec.id: status}, # override of trace_status (mail only)
'records_to_number': {rec.id: number}, # override of trace_sms_number (SMS / WA)
'records_to_partner: {rec.id: <res.partner>}, # linked partner (recipient) (mail / WA)
}, {}, ... ]
:param activity: a marketing.activity on which marketing traces are about
to be checked, as well as sub records like mailing.trace if requested
by status;
:param strict: whether activity traces must match given records IDs;
:param canceled_res_ids: quick check for canceled marketing traces not given
in participants_info (e.g. unlinked records, quick validation, ...);
"""
all_records, all_records_unlinked = self.env[activity.campaign_id.model_name], self.env[activity.campaign_id.model_name]
for info in participants_info:
if not info.get('records_missing'):
all_records += info['records']
else:
all_records_unlinked += info['records']
# find traces linked to activity, ensure we have one trace / record
traces = self.env['marketing.trace'].search([
('activity_id', 'in', activity.ids),
])
traces_info = [f'Checking for activity {activity.name} ({activity.activity_type}, {activity.trigger_type})']
for trace in traces:
record = all_records.filtered(lambda r: r.id == trace.res_id)
record_info = "-no record found in assert-"
if record:
record_info = f"ID.{record.id}, {record.display_name}: email {record.email_normalized}"
if "mobile" in record:
record_info += f"- mobile {record.mobile}"
if "phone" in record:
record_info += f"- phone {record.phone}"
elif record_unlinked := all_records_unlinked.filtered(lambda r: r.id == trace.res_id):
record_info = f"-unlinked ID.{record_unlinked.id}"
traces_info.append(
f'Trace: doc {trace.res_id} - activity {trace.activity_id.id} ({trace.activity_id.activity_type}) - status {trace.state}'
f' - rec: {record_info}'
)
debug_info = '\n'.join(traces_info)
# check traces / records coherency through campaign
canceled_res_ids = canceled_res_ids or set()
all_record_ids = set(all_records.ids) | set(all_records_unlinked.ids) | canceled_res_ids
if strict:
additional = set(traces.mapped('res_id')) - all_record_ids
missing = all_record_ids - set(traces.mapped('res_id'))
self.assertEqual(
set(traces.mapped('res_id')), all_record_ids,
f'Should find one trace / record. Missing traces {missing} - Unexpected traces {additional}. Found\n{debug_info}'
)
self.assertEqual(
len(traces), len(all_records) + len(all_records_unlinked) + len(canceled_res_ids),
f'Should find one trace / record. Found\n{debug_info}'
)
else:
self.assertTrue(set(all_record_ids) < set(traces.mapped('res_id')))
for canceled_res_id in canceled_res_ids:
linked_trace = traces.filtered(lambda t: t.res_id == canceled_res_id)
self.assertTrue(linked_trace)
self.assertEqual(linked_trace.state, 'canceled')
for info in participants_info:
# check input
invalid = set(info.keys()) - {
# participants
'participants',
'participants_state',
# marketing trace
'fields_values',
'status', # marketing.trace status
# records
'records',
'records_missing',
# mail
'check_mail',
'email_values',
'mailing_trace_values',
'mail_values',
'trace_content',
'trace_email',
'trace_email_to_mail',
'trace_email_to_recipients',
'trace_failure_reason',
'trace_failure_type',
'trace_status', # mailing.trace status
# sms (see sms modules)
'check_sms',
'sms_values',
'sent_unlink', # whether sms are unlinked, hence checking IAP mock data directly
'trace_sms_number',
# whatsapp (see wa modules)
'wa_from_mock',
'wa_msg_values',
# record-based info
'records_to_email_to_mail',
'records_to_email_to_recipients',
'records_to_trace_email',
'records_to_trace_status',
'records_to_number',
'records_to_partner',
}
if invalid:
raise AssertionError(f"assertMarketAutoTraces: invalid input {invalid}")
records = info['records']
linked_traces = traces.filtered(lambda t: t.res_id in records.ids)
# check link to records, continue if no records (aka no traces)
if not records:
self.assertFalse(linked_traces)
continue
self.assertEqual(set(linked_traces.mapped('res_id')), set(records.ids))
# check trace details
fields_values = info.get('fields_values') or {}
for trace in linked_traces:
record = records.filtered(lambda r: r.id == trace.res_id)
if not info.get('records_missing'):
trace_info = f'Trace {trace.id}: doc {trace.res_id} ({record.email_normalized}-{record.name})'
else:
trace_info = f'Trace {trace.id}: doc {trace.res_id} (unlinked record)'
# asked marketing.trace values
self.assertEqual(
trace.state, info['status'],
f"Received {trace.state} instead of {info['status']} for {trace_info}\nDebug\n{debug_info}")
for fname, fvalue in fields_values.items():
with self.subTest(fname=fname, fvalue=fvalue):
self.assertEqual(
trace[fname], fvalue,
f'Marketing Trace: expected {fvalue} for {fname}, got {trace[fname]} for {trace_info}'
)
# check sub-records (mailing related notably)
if info.get('trace_status') and activity.activity_type == 'email':
self.assertMarketAutoTracesMail(info, activity, traces)
elif not info.get('trace_status'):
self.assertEqual(linked_traces.mailing_trace_ids, self.env['mailing.trace'])
# marketing.participant check
if participants := info.get('participants'):
self.assertEqual(linked_traces.participant_id, participants)
if participants_state := info.get('participants_state'):
self.assertEqual(set(linked_traces.participant_id.mapped('state')), {participants_state})
return traces
def assertMarketAutoTracesMail(self, participant_info, activity, traces):
# prepare optional record-specific values
partners = participant_info.get('records_to_partner', {})
trace_emails = participant_info.get('records_to_trace_email', {})
mail_emails = participant_info.get('records_to_email_to_mail', {})
email_emails = participant_info.get('records_to_email_to_recipients', {})
statuses = participant_info.get('records_to_trace_status', {})
records_add_info = []
for record in participant_info['records']:
add_info = {
'email': trace_emails.get(record.id, participant_info.get('trace_email', record.email_normalized)),
'partner': partners.get(record.id) or self.env['res.partner'],
'trace_status': statuses.get(record.id) or participant_info['trace_status'],
}
if record.id in mail_emails:
add_info['email_to_mail'] = mail_emails[record.id]
elif 'trace_email_to_mail' in participant_info:
add_info['email_to_mail'] = participant_info['trace_email_to_mail']
elif not partners.get(record.id):
add_info['email_to_mail'] = record[record._primary_email] or ''
if record.id in email_emails:
add_info['email_to_recipients'] = email_emails[record.id]
elif 'trace_email_to_recipients' in participant_info:
add_info['email_to_recipients'] = participant_info['trace_email_to_recipients']
records_add_info.append(add_info)
self.assertMailTraces(
[{
# record info
'record': record,
# trace / mail.mail
'failure_type': participant_info.get('trace_failure_type', False),
'failure_reason': participant_info.get('trace_failure_reason', False),
'trace_values': participant_info.get('mailing_trace_values') or {},
# mail.mail
'content': participant_info.get('trace_content'),
'mail_values': participant_info.get('mail_values'),
# outgoing email
'email_values': participant_info.get('email_values'),
# other precomputed info (email, partner, ...)
**add_info,
} for record, add_info in zip(participant_info['records'], records_add_info)
],
activity.mass_mailing_id,
participant_info['records'],
check_mail=participant_info.get('check_mail', True),
)
def assertActivityProcessed(self, activities, records, sd=None):
""" Quick assert """
return self._assertActivityStatus(activities, records, 'processed', sd=sd)
def assertActivityScheduled(self, activities, records, sd=None):
""" Quick assert """
return self._assertActivityStatus(activities, records, 'scheduled', sd=sd)
def _assertActivityStatus(self, activities, records, status, sd=None):
""" Quick assert, hiding a few stuff hence to use only when caring about
global details. """
for activity in activities:
if status == 'processed':
traces = self.env['marketing.trace'].search([
('activity_id', 'in', activity.ids),
])
self.assertEqual(len(traces), len(records))
states = set(traces.mapped('state'))
self.assertFalse(states & {'scheduled', 'rejected'})
continue
fields_values = {}
if sd is not None:
fields_values['schedule_date'] = sd
assert_values = {
'check_mail': False, # too complicated
'check_sms': False, # too complicated
'fields_values': fields_values,
'records': records,
'status': status,
}
if (
activity.activity_type == 'email' and status == 'processed' or
activity.activity_type == 'sms' and status == 'processed'
):
assert_values['trace_status'] = 'sent'
with self.subTest(activity_name=activity.name):
self.assertMarketAutoTraces(
[assert_values],
activity,
)
def assertActivityWoTrace(self, activities):
""" Ensure activity has no traces linked to it """
for activity in activities:
with self.subTest(activity=activity):
self.assertMarketAutoTraces([{'records': self.env[activity.model_name]}], activity)
# ------------------------------------------------------------
# CRON TOOLS AND ASSERTS
# ------------------------------------------------------------
def _assert_cron_progress(self, cron, count, done=0, remaining=0, deactivate=False, timed_out_counter=0):
progresses = self.env['ir.cron.progress'].search([('cron_id', '=', cron.id)], order='id DESC')
self.assertEqual(len(progresses), count)
if count:
self.assertEqual(progresses[0].deactivate, deactivate)
self.assertEqual(progresses[0].done, done)
self.assertEqual(progresses[0].remaining, remaining)
self.assertEqual(progresses[0].timed_out_counter, timed_out_counter)
def _assert_cron_state(self, cron, **fields_values):
fields_values.setdefault('active', True)
fields_values.setdefault('failure_count', 0)
fields_values.setdefault('first_failure_date', False)
for fname, fvalue in fields_values.items():
self.assertEqual(cron[fname], fvalue,
f'Cron {cron.name} invalid value for {fname}: expected {fvalue}, got {cron[fname]}')
# ------------------------------------------------------------
# RECORDS TOOLS
# ------------------------------------------------------------
@classmethod
def _create_mailing(cls, model, user=None, **mailing_values):
mailing_type = mailing_values.get("mailing_type", "mail")
vals = {
'body_html': """<div><p>Hello {{ object.name }}<br/>You rock</p>
<p>click here <a id="url0" href="https://www.example.com/foo/bar?baz=qux">LINK</a></p>
</div>""",
'mailing_model_id': cls.env['ir.model']._get_id(model),
'mailing_type': mailing_type,
'name': 'SourceName',
'preview': 'Hi {{ object.name }} :)',
'reply_to_mode': 'update',
'subject': 'Test for {{ object.name }}',
'use_in_marketing_automation': True,
}
if mailing_type == 'mail':
vals['body_html'] = """<div><p>Hello {{ object.name }}<br/>You rock</p>
<p>click here <a id="url0" href="https://www.example.com/foo/bar?baz=qux">LINK</a></p>
</div>"""
else:
vals['body_plaintext'] = "Test SMS for {{ object.name }} click on https://www.example.com/foo/bar?baz=qux"
if user:
vals['email_from'] = user.email_formatted
vals['user_id'] = user.id
vals.update(**mailing_values)
return cls.env['mailing.mailing'].create(vals)
@classmethod
def _create_server_action(cls, model, code, **sa_values):
vals = {
"code": code,
"model_id": cls.env["ir.model"]._get_id(model),
"name": "Test SA",
"state": "code",
}
vals.update(**sa_values)
return cls.env['ir.actions.server'].create(vals)
@classmethod
def _create_activity(cls, campaign, mailing=None, wa_template=None, action=None, **act_values):
vals = {}
if mailing:
if mailing.mailing_type == 'mail':
vals.update({
'mass_mailing_id': mailing.id,
'activity_type': 'email',
})
else:
vals.update({
'mass_mailing_id': mailing.id,
'activity_type': 'sms',
})
elif wa_template:
vals.update({
'activity_type': 'whatsapp',
'whatsapp_template_id': wa_template.id,
})
elif action:
vals.update({
'server_action_id': action.id,
'activity_type': 'action',
})
vals.update({
'name': f'Activity {len(campaign.marketing_activity_ids) + 1} ({vals["activity_type"]} on {act_values.get("trigger_type")})',
'campaign_id': campaign.id,
})
vals.update(**act_values)
if act_values.get('create_date'):
with patch.object(cls.env.cr, 'now', lambda: act_values['create_date']):
activity = cls.env['marketing.activity'].create(vals)
else:
activity = cls.env['marketing.activity'].create(vals)
return activity
@classmethod
def _create_activity_mail(cls, campaign, user=None, mailing_values=None, act_values=None):
new_mailing = cls._create_mailing(campaign.model_name, user=user, **(mailing_values or {}))
return cls._create_activity(campaign, mailing=new_mailing, **(act_values or {}))
@classmethod
def _create_activity_sa(cls, campaign, code, sa_values=None, act_values=None):
new_sa = cls._create_server_action(campaign.model_name, code, **(sa_values or {}))
return cls._create_activity(campaign, action=new_sa, **(act_values or {}))
@classmethod
def _create_activity_wa(cls, campaign, user=None, template_values=None, act_values=None):
new_wa_template = cls._create_wa_template(campaign.model_name, user=user, **(template_values or {}))
return cls._create_activity(campaign, wa_template=new_wa_template, **(act_values or {}))
def _force_activity_create_date(self, activities, create_date):
""" As create_date is set through sql NOW it is not possible to mock
it easily. """
self.env.cr.execute(
"UPDATE marketing_activity SET create_date=%s WHERE id IN %s",
(create_date, tuple(activities.ids),)
)
def _launch_campaign(self, campaign, date_reference=None):
with self.mock_datetime_and_now(date_reference or fields.Datetime.now()):
campaign.action_start_campaign()
campaign.sync_participants()
class MarketingAutomationCommon(MarketingAutomationCase, MassMailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_marketing_automation = mail_new_test_user(
cls.env,
email='[email protected]',
groups='base.group_user,base.group_partner_manager,marketing_automation.group_marketing_automation_user',
login='user_marketing_automation',
name='Mounhir MarketAutoUser',
signature='--\nM'
)
cls.test_countries = [
cls.env['res.country'],
cls.env.ref('base.be'),
cls.env.ref('base.in'),
cls.env.ref('base.us'),
cls.env.ref('base.fr'),
]
cls.test_contacts = cls.env['mailing.contact'].create([
{
'country_id': cls.test_countries[idx % len(cls.test_countries)].id,
'email': f'ma.test.contact.{idx}@example.com',
'name': f'MATest_{idx:02d}',
}
for idx in range(10)
])
cls.campaign = cls.env['marketing.campaign'].create({
'domain': [('name', 'like', 'MATest')],
'model_id': cls.env['ir.model']._get_id('mailing.contact'),
'name': 'Test Campaign',
})
# ensure batch size for generation to have deterministic tests and check iterative behavior
cls.env["ir.config_parameter"].sudo().set_param("mail.batch_size", 50)