forked from Mapan/odoo17e
574 lines
24 KiB
Python
574 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
from freezegun import freeze_time
|
|
|
|
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
|
|
from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE
|
|
from odoo.addons.test_marketing_automation.tests.common import TestMACommon
|
|
from odoo.fields import Datetime
|
|
from odoo.tests import tagged, users
|
|
from odoo.tools import mute_logger
|
|
|
|
|
|
@tagged('marketing_automation')
|
|
class TestMarketAutoFlow(TestMACommon, CronMixinCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(TestMarketAutoFlow, cls).setUpClass()
|
|
cls.date_reference = Datetime.from_string('2014-08-01 15:02:32') # so long, little task
|
|
cls.env['res.lang']._activate_lang('fr_FR')
|
|
|
|
# --------------------------------------------------
|
|
# TEST RECORDS, using marketing.test.sms (customers)
|
|
#
|
|
# 2 times
|
|
# - 3 records with partners
|
|
# - 1 records wo partner, but email/mobile
|
|
# - 1 record wo partner/email/mobile
|
|
# 1 wrong email
|
|
# 1 duplicate)
|
|
# --------------------------------------------------
|
|
cls.test_records_base = cls._create_marketauto_records(model='marketing.test.sms', count=2)
|
|
cls.test_records_failure = cls.env['marketing.test.sms'].create([
|
|
{
|
|
'email_from': 'wrong',
|
|
'mobile': '0455990000',
|
|
'name': 'Wrong Email',
|
|
}, {
|
|
'email_from': cls.test_records_base[1].email_from,
|
|
'mobile': cls.test_records_base[1].mobile,
|
|
# compared to < 17, we need the name to be the same, as duplicate
|
|
# comparison is now done on sent content + recipient, not just
|
|
# the recipient itself
|
|
'name': cls.test_records_base[1].name,
|
|
},
|
|
])
|
|
(cls.test_records_failure_wrong, cls.test_records_failure_dupe) = cls.test_records_failure
|
|
cls.test_records = cls.test_records_base + cls.test_records_failure
|
|
|
|
# --------------------------------------------------
|
|
# CAMPAIGN, based on marketing.test.sms (customers)
|
|
#
|
|
# ACT1 MAIL mailing
|
|
# ACT2.1 -> reply -> send an SMS after 1h with a promotional link
|
|
# ACT3.1 -> sms_click -> send a confirmation SMS right at click
|
|
# ACT2.2 -> not opened within 1 day-> update description through server action
|
|
# --------------------------------------------------
|
|
|
|
cls.campaign = cls.env['marketing.campaign'].with_user(cls.user_marketing_automation).create({
|
|
'domain': [('name', '!=', 'Invalid')],
|
|
'model_id': cls.env['ir.model']._get_id('marketing.test.sms'),
|
|
'name': 'Test Campaign',
|
|
})
|
|
# first activity: send a mailing
|
|
cls.act1_mailing = cls._create_mailing(
|
|
'marketing.test.sms',
|
|
email_from=cls.user_marketing_automation.email_formatted,
|
|
keep_archives=True,
|
|
).with_user(cls.user_marketing_automation)
|
|
cls.act1 = cls._create_activity(
|
|
cls.campaign,
|
|
mailing=cls.act1_mailing,
|
|
trigger_type='begin',
|
|
interval_number=0,
|
|
).with_user(cls.user_marketing_automation)
|
|
|
|
# second activity: send an SMS 1 hour after a reply
|
|
cls.act2_1_mailing = cls._create_mailing(
|
|
'marketing.test.sms',
|
|
mailing_type='sms',
|
|
body_plaintext='SMS for {{ object.name }}: mega promo on https://test.example.com',
|
|
sms_allow_unsubscribe=True,
|
|
).with_user(cls.user_marketing_automation)
|
|
cls.act2_1 = cls._create_activity(
|
|
cls.campaign,
|
|
mailing=cls.act2_1_mailing,
|
|
parent_id=cls.act1.id,
|
|
trigger_type='mail_reply',
|
|
interval_number=1, interval_type='hours',
|
|
).with_user(cls.user_marketing_automation)
|
|
# other activity: update description if not opened after 1 day
|
|
# created by admin, should probably not give rights to marketing
|
|
cls.act2_2_sact = cls.env['ir.actions.server'].create({
|
|
'code': """
|
|
for record in records:
|
|
record.write({'description': record.description + ' - Did not answer, sad campaign is sad.'})""",
|
|
'model_id': cls.env['ir.model']._get('marketing.test.sms').id,
|
|
'name': 'Update description',
|
|
'state': 'code',
|
|
})
|
|
cls.act2_2 = cls._create_activity(
|
|
cls.campaign,
|
|
action=cls.act2_2_sact,
|
|
parent_id=cls.act1.id,
|
|
trigger_type='mail_not_open',
|
|
interval_number=1, interval_type='days',
|
|
activity_domain=[('email_from', '!=', False)],
|
|
).with_user(cls.user_marketing_automation)
|
|
|
|
cls.act3_1_mailing = cls._create_mailing(
|
|
'marketing.test.sms',
|
|
mailing_type='sms',
|
|
body_plaintext='Confirmation for {{ object.name }}',
|
|
sms_allow_unsubscribe=False,
|
|
).with_user(cls.user_marketing_automation)
|
|
cls.act3_1 = cls._create_activity(
|
|
cls.campaign,
|
|
mailing=cls.act3_1_mailing,
|
|
parent_id=cls.act2_1.id,
|
|
trigger_type='sms_click',
|
|
interval_number=0,
|
|
).with_user(cls.user_marketing_automation)
|
|
|
|
cls.env.flush_all()
|
|
|
|
def test_assert_initial_values(self):
|
|
""" Test initial values to have a common ground for other tests """
|
|
# ensure initial data
|
|
self.assertEqual(len(self.test_records), 12)
|
|
self.assertEqual(len(self.test_records.filtered(lambda r: r.name != 'Test_00')), 11)
|
|
self.assertEqual(self.campaign.state, 'draft')
|
|
|
|
@mute_logger('odoo.addons.base.models.ir_model',
|
|
'odoo.addons.mail.models.mail_mail',
|
|
'odoo.addons.mass_mailing.models.mailing',
|
|
'odoo.addons.mass_mailing_sms.models.mailing_mailing')
|
|
@users('user_marketing_automation')
|
|
def test_marketing_automation_flow(self):
|
|
""" Test a marketing automation flow involving several steps. """
|
|
# init test variables to ease code reading
|
|
date_reference = self.date_reference
|
|
test_records = self.test_records.with_user(self.env.user)
|
|
test_records_init = test_records.filtered(lambda r: r.name != 'Test_00')
|
|
|
|
# update campaign
|
|
act1 = self.act1.with_user(self.env.user)
|
|
act2_1 = self.act2_1.with_user(self.env.user)
|
|
act2_2 = self.act2_2.with_user(self.env.user)
|
|
act3_1 = self.act3_1.with_user(self.env.user)
|
|
campaign = self.campaign.with_user(self.env.user)
|
|
campaign.write({
|
|
'domain': [('name', '!=', 'Test_00')],
|
|
})
|
|
|
|
# CAMPAIGN START
|
|
# ------------------------------------------------------------
|
|
|
|
# User starts and syncs its campaign
|
|
with freeze_time(self.date_reference), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers:
|
|
campaign.action_start_campaign()
|
|
self.assertEqual(campaign.state, 'running')
|
|
|
|
# a cron.trigger has been created to sync participants after campaign start
|
|
self.assertEqual(len(captured_triggers.records), 1)
|
|
self.assertEqual(
|
|
captured_triggers.records[0].cron_id,
|
|
self.env.ref('marketing_automation.ir_cron_campaign_sync_participants'))
|
|
self.assertEqual(captured_triggers.records[0].call_at, self.date_reference)
|
|
|
|
with freeze_time(date_reference), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
campaign.sync_participants()
|
|
|
|
# All records not containing Test_00 should be added as participants
|
|
self.assertEqual(campaign.running_participant_count, len(test_records_init))
|
|
self.assertEqual(
|
|
set(campaign.participant_ids.mapped('res_id')),
|
|
set(test_records_init.ids)
|
|
)
|
|
self.assertEqual(
|
|
set(campaign.participant_ids.mapped('state')),
|
|
set(['running'])
|
|
)
|
|
|
|
# Beginning activity should contain a scheduled trace for each participant
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'scheduled',
|
|
'records': test_records_init,
|
|
'participants': campaign.participant_ids,
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
},
|
|
}],
|
|
act1,
|
|
)
|
|
|
|
# a cron.trigger has been created to execute activities after campaign start
|
|
# there should only be one since we have 9 activities with the same scheduled_date
|
|
self.assertEqual(len(captured_triggers.records), 1)
|
|
self.assertEqual(
|
|
captured_triggers.records[0].cron_id,
|
|
self.env.ref('marketing_automation.ir_cron_campaign_execute_activities'))
|
|
self.assertEqual(captured_triggers.records[0].call_at, self.date_reference)
|
|
|
|
# No other trace should have been created as the first one are waiting to be processed
|
|
self.assertEqual(act2_1.trace_ids, self.env['marketing.trace'])
|
|
self.assertEqual(act2_2.trace_ids, self.env['marketing.trace'])
|
|
self.assertEqual(act3_1.trace_ids, self.env['marketing.trace'])
|
|
|
|
# ACT1: LAUNCH MAILING
|
|
# ------------------------------------------------------------
|
|
test_records_1_ko = test_records_init.filtered(
|
|
lambda r: not r.email_from or r.email_from == 'wrong'
|
|
) + self.test_records_failure_dupe
|
|
test_records_1_ok = test_records_init.filtered(lambda r: r not in test_records_1_ko)
|
|
|
|
# First traces are processed, emails are sent (or failed)
|
|
with freeze_time(self.date_reference), \
|
|
self.mock_mail_gateway(), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
campaign.execute_activities()
|
|
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'processed',
|
|
'records': test_records_1_ok,
|
|
'trace_status': 'sent',
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
},
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': self.test_records_failure_wrong,
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
'state_msg': 'Email canceled',
|
|
},
|
|
# wrong email -> trace set as ignored
|
|
'trace_email': self.test_records_failure_wrong.email_from,
|
|
'trace_failure_type': 'mail_email_invalid',
|
|
'trace_status': 'cancel',
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': self.test_records_failure_dupe,
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
'state_msg': 'Email canceled',
|
|
},
|
|
# wrong email -> trace set as ignored
|
|
'trace_email': self.test_records_failure_dupe.email_normalized,
|
|
'trace_failure_type': 'mail_dup',
|
|
'trace_status': 'cancel',
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': (test_records_1_ko - self.test_records_failure_wrong - self.test_records_failure_dupe),
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
'state_msg': 'Email canceled',
|
|
},
|
|
# no email -> trace set as ignored
|
|
'trace_failure_type': 'mail_email_missing',
|
|
'trace_status': 'cancel',
|
|
}],
|
|
act1,
|
|
)
|
|
|
|
# Child traces should have been generated for all traces of parent activity as activity_domain
|
|
# is taken into account at processing, not generation (see act2_2)
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'scheduled',
|
|
'records': test_records_init,
|
|
'participants': campaign.participant_ids,
|
|
'fields_values': {
|
|
'schedule_date': False,
|
|
},
|
|
}],
|
|
act2_1,
|
|
)
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'scheduled',
|
|
'records': test_records_init,
|
|
'participants': campaign.participant_ids,
|
|
'fields_values': {
|
|
'schedule_date': date_reference + relativedelta(days=1),
|
|
},
|
|
}],
|
|
act2_2,
|
|
)
|
|
|
|
# a cron.trigger has been created to execute activities 1 day after mailing is sent
|
|
# there should only be one since we have 9 activities with the same scheduled_date
|
|
self.assertEqual(1, len(captured_triggers.records))
|
|
captured_trigger = captured_triggers.records[0]
|
|
self.assertEqual(
|
|
self.env.ref('marketing_automation.ir_cron_campaign_execute_activities'),
|
|
captured_trigger.cron_id)
|
|
self.assertEqual(self.date_reference + relativedelta(days=1), captured_trigger.call_at)
|
|
|
|
# Processing does not change anything (not time yet)
|
|
with freeze_time(self.date_reference):
|
|
campaign.execute_activities()
|
|
self.assertEqual(set(act2_1.trace_ids.mapped('state')), set(['scheduled']))
|
|
self.assertEqual(set(act2_2.trace_ids.mapped('state')), set(['scheduled']))
|
|
|
|
# ACT1 FOLLOWUP: PROCESS SOME REPLIES (+1 H)
|
|
# ------------------------------------------------------------
|
|
|
|
date_reference_reply = date_reference + relativedelta(hours=1)
|
|
test_records_1_replied = test_records_1_ok[:2]
|
|
with freeze_time(date_reference_reply), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
for record in test_records_1_replied:
|
|
self.gateway_mail_reply_wrecord(MAIL_TEMPLATE, record)
|
|
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'processed',
|
|
'records': test_records_1_replied,
|
|
'trace_status': 'reply',
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
},
|
|
}, {
|
|
'status': 'processed',
|
|
'records': test_records_1_ok - test_records_1_replied,
|
|
'trace_status': 'sent',
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
},
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': self.test_records_failure_wrong,
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
'state_msg': 'Email canceled',
|
|
},
|
|
# wrong email -> trace set as ignored
|
|
'trace_email': self.test_records_failure_wrong.email_from,
|
|
'trace_failure_type': 'mail_email_invalid',
|
|
'trace_status': 'cancel',
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': self.test_records_failure_dupe,
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
'state_msg': 'Email canceled',
|
|
},
|
|
# wrong email -> trace set as ignored
|
|
'trace_email': self.test_records_failure_dupe.email_normalized,
|
|
'trace_failure_type': 'mail_dup',
|
|
'trace_status': 'cancel',
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': (test_records_1_ko - self.test_records_failure_wrong - self.test_records_failure_dupe),
|
|
'fields_values': {
|
|
'schedule_date': date_reference,
|
|
'state_msg': 'Email canceled',
|
|
},
|
|
# no email -> trace set as ignored
|
|
'trace_failure_type': 'mail_email_missing',
|
|
'trace_status': 'cancel',
|
|
}],
|
|
act1,
|
|
)
|
|
|
|
# Replied records -> SMS scheduled
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'scheduled',
|
|
'records': test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': date_reference_reply + relativedelta(hours=1),
|
|
},
|
|
}, {
|
|
'status': 'scheduled',
|
|
'records': test_records_init - test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': False,
|
|
},
|
|
}],
|
|
act2_1,
|
|
)
|
|
# Replied records -> mail_not_open canceled
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'scheduled',
|
|
'records': test_records_init - test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': date_reference + relativedelta(days=1),
|
|
},
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': date_reference_reply,
|
|
},
|
|
}],
|
|
act2_2,
|
|
)
|
|
|
|
# a cron.trigger has been created after each separate reply exactly 1 hour after the reply
|
|
# to match the created marketing.trace (ACT2.1)
|
|
# (here we have 2 replies considered at the exact same time but real use cases will most
|
|
# likely not)
|
|
self.assertEqual(len(captured_triggers.records), 2)
|
|
for captured_trigger in captured_triggers.records:
|
|
self.assertEqual(
|
|
captured_trigger.cron_id,
|
|
self.env.ref('marketing_automation.ir_cron_campaign_execute_activities'))
|
|
self.assertEqual(captured_trigger.call_at, date_reference_reply + relativedelta(hours=1))
|
|
|
|
# ACT2_1: REPLIED GOT AN SMS (+2 H)
|
|
# ------------------------------------------------------------
|
|
|
|
date_reference_new = date_reference + relativedelta(hours=2)
|
|
|
|
with freeze_time(date_reference_new), \
|
|
self.mockSMSGateway(), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
campaign.execute_activities()
|
|
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'processed',
|
|
'records': test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': date_reference_reply + relativedelta(hours=1),
|
|
},
|
|
'trace_status': 'outgoing',
|
|
}, {
|
|
'status': 'scheduled',
|
|
'records': test_records_init - test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': False,
|
|
},
|
|
}],
|
|
act2_1,
|
|
)
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'scheduled',
|
|
'records': test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': False,
|
|
},
|
|
}],
|
|
act3_1,
|
|
)
|
|
|
|
self.assertFalse(captured_triggers.records) # no trigger should be created
|
|
|
|
with freeze_time(date_reference_new), \
|
|
self.mockSMSGateway(), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
self.env['sms.sms'].sudo()._process_queue()
|
|
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'processed',
|
|
'records': test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': date_reference_reply + relativedelta(hours=1),
|
|
},
|
|
'trace_status': 'pending',
|
|
}, {
|
|
'status': 'scheduled',
|
|
'records': test_records_init - test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': False,
|
|
},
|
|
}],
|
|
act2_1,
|
|
)
|
|
|
|
self.assertFalse(captured_triggers.records) # no trigger should be created
|
|
|
|
# ACT2_1 FOLLOWUP: CLICK ON LINKS -> ACT3_1: CONFIRMATION SMS SENT
|
|
# ------------------------------------------------------------
|
|
|
|
self._clear_outgoing_sms()
|
|
# TDE CLEANME: improve those tools, but sms gateway resets finding existing
|
|
# sms, which is why we do in two steps
|
|
test_records_1_clicked = test_records_1_replied[0]
|
|
sms_sent = self._find_sms_sent(test_records_1_clicked.customer_id, test_records_1_clicked.phone_sanitized)
|
|
|
|
# mock SMS gateway as in the same transaction, next activity is processed
|
|
with freeze_time(date_reference_new), \
|
|
self.mockSMSGateway(), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
self.gateway_sms_sent_click(sms_sent)
|
|
|
|
self.assertFalse(captured_triggers.records) # no trigger should be created
|
|
|
|
with freeze_time(date_reference_new), \
|
|
self.mockSMSGateway(), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
self.env['sms.sms'].sudo()._process_queue()
|
|
|
|
self.assertFalse(captured_triggers.records) # no trigger should be created
|
|
|
|
# click triggers process_event and automatically launches act3_1 depending on sms_click
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'processed',
|
|
'records': test_records_1_clicked,
|
|
'fields_values': {
|
|
'schedule_date': date_reference_new,
|
|
},
|
|
# mailing trace
|
|
'trace_content': f'Confirmation for {test_records_1_clicked.name}',
|
|
'trace_status': 'pending',
|
|
}, {
|
|
'status': 'scheduled',
|
|
'records': test_records_1_replied - test_records_1_clicked,
|
|
'fields_values': {
|
|
'schedule_date': False,
|
|
},
|
|
}],
|
|
act3_1,
|
|
)
|
|
|
|
# ACT2_2: PROCESS SERVER ACTION ON NOT-REPLIED (+1D 2H)
|
|
# ------------------------------------------------------------
|
|
|
|
date_reference_new = date_reference + relativedelta(days=1, hours=2)
|
|
self._clear_outgoing_sms()
|
|
with freeze_time(date_reference_new), \
|
|
mute_logger('odoo.addons.marketing_automation.models.marketing_activity'), \
|
|
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers:
|
|
campaign.execute_activities()
|
|
|
|
self.assertMarketAutoTraces(
|
|
[{
|
|
'status': 'processed',
|
|
'records': test_records_1_ok - test_records_1_replied,
|
|
'fields_values': {
|
|
'schedule_date': date_reference_new,
|
|
},
|
|
}, {
|
|
'status': 'error',
|
|
'records': (self.test_records_failure_wrong + self.test_records_failure_dupe), # server action did crash, description is False (see muted logger)
|
|
'fields_values': {
|
|
'schedule_date': date_reference_new,
|
|
'state_msg_content': 'Exception in server action',
|
|
},
|
|
}, {
|
|
'status': 'rejected',
|
|
'records': (test_records_1_ko - self.test_records_failure_wrong - self.test_records_failure_dupe), # no email_from -> rejected due to domain filter
|
|
'fields_values': {
|
|
'schedule_date': date_reference + relativedelta(days=1),
|
|
},
|
|
}, {
|
|
'status': 'canceled',
|
|
'records': test_records_1_replied, # replied -> mail_not_open is canceled
|
|
'fields_values': {
|
|
'schedule_date': date_reference_reply,
|
|
},
|
|
}],
|
|
act2_2,
|
|
)
|
|
|
|
# check server action was actually processed
|
|
for record in test_records_1_ko | test_records_1_replied:
|
|
self.assertNotIn('Did not answer, sad campaign is sad', (record.description or ''))
|
|
for record in test_records_1_ok - test_records_1_replied:
|
|
self.assertIn('Did not answer, sad campaign is sad', (record.description or ''))
|
|
self.assertFalse(captured_triggers.records) # no trigger should be created
|