diff --git a/addons/calendar/__manifest__.py b/addons/calendar/__manifest__.py
index 420e059dc7b..071e71a9f31 100644
--- a/addons/calendar/__manifest__.py
+++ b/addons/calendar/__manifest__.py
@@ -36,6 +36,7 @@ If you need to manage your meetings, you should install the CRM module.
'views/res_partner_views.xml',
'wizard/calendar_provider_config.xml',
'wizard/calendar_popover_delete_wizard.xml',
+ 'wizard/mail_activity_schedule_views.xml',
],
'installable': True,
'application': True,
diff --git a/addons/calendar/wizard/__init__.py b/addons/calendar/wizard/__init__.py
index 7ec0558710c..37d30a78293 100644
--- a/addons/calendar/wizard/__init__.py
+++ b/addons/calendar/wizard/__init__.py
@@ -4,3 +4,4 @@
from . import calendar_provider_config
from . import calendar_popover_delete_wizard
+from . import mail_activity_schedule
diff --git a/addons/calendar/wizard/mail_activity_schedule.py b/addons/calendar/wizard/mail_activity_schedule.py
new file mode 100644
index 00000000000..190c1bb2cf3
--- /dev/null
+++ b/addons/calendar/wizard/mail_activity_schedule.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+from odoo.exceptions import UserError
+from odoo.tools.translate import _
+
+
+class MailActivitySchedule(models.TransientModel):
+ _inherit = 'mail.activity.schedule'
+
+ def action_create_calendar_event(self):
+ self.ensure_one()
+ if self.is_batch_mode:
+ raise UserError(_("Scheduling an activity using the calendar is not possible on more than one record."))
+ return self.with_context({
+ 'default_res_model': self.res_model,
+ 'default_res_id': self._evaluate_res_ids()[0],
+ })._action_schedule_activities().action_create_calendar_event()
diff --git a/addons/calendar/wizard/mail_activity_schedule_views.xml b/addons/calendar/wizard/mail_activity_schedule_views.xml
new file mode 100644
index 00000000000..aee26589d73
--- /dev/null
+++ b/addons/calendar/wizard/mail_activity_schedule_views.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ mail.activity.schedule.inherit.calendar
+ mail.activity.schedule
+
+
+
+ activity_category == 'meeting'
+
+
+ activity_category == 'meeting'
+
+
+ activity_category in ('meeting', 'phonecall') or id
+
+
+ activity_category == 'meeting'
+
+
+
+
+
+
+
+
diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py
index b688d53a110..eb6bf1343d1 100644
--- a/addons/crm/__manifest__.py
+++ b/addons/crm/__manifest__.py
@@ -49,6 +49,7 @@
'views/crm_lead_views.xml',
'views/crm_team_member_views.xml',
'views/digest_views.xml',
+ 'views/mail_activity_plan_views.xml',
'views/mail_activity_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
diff --git a/addons/crm/security/crm_security.xml b/addons/crm/security/crm_security.xml
index 0b2f2e8c1b4..ad35e2915ac 100644
--- a/addons/crm/security/crm_security.xml
+++ b/addons/crm/security/crm_security.xml
@@ -58,6 +58,22 @@
['|',('company_id','=',False),('company_id', 'in', company_ids)]
+
+ Manager can manage lead plans
+
+
+ [('res_model', '=', 'crm.lead')]
+
+
+
+
+ Manager can manage lead plan templates
+
+
+ [('plan_id.res_model', '=', 'crm.lead')]
+
+
+
diff --git a/addons/crm/security/ir.model.access.csv b/addons/crm/security/ir.model.access.csv
index f936473ab4d..7c75a7e9798 100644
--- a/addons/crm/security/ir.model.access.csv
+++ b/addons/crm/security/ir.model.access.csv
@@ -29,3 +29,5 @@ access_crm_merge_opportunity,access.crm.merge.opportunity,model_crm_merge_opport
crm_recurring_plan_access_manager,crm.recurring.plan.access.manager,model_crm_recurring_plan,sales_team.group_sale_manager,1,1,1,1
crm_recurring_plan_access_salesman,crm.recurring.plan.access.salesman,model_crm_recurring_plan,sales_team.group_sale_salesman,1,0,0,0
crm_lead_pls_update_access_system,crm.lead.pls.update.access.system,model_crm_lead_pls_update,base.group_erp_manager,1,1,1,1
+access_mail_activity_plan_sale_manager,mail.activity.plan.sale.manager,mail.model_mail_activity_plan,sales_team.group_sale_manager,1,1,1,1
+access_mail_activity_plan_template_sale_manager,mail.activity.plan.template.sale.manager,mail.model_mail_activity_plan_template,sales_team.group_sale_manager,1,1,1,1
diff --git a/addons/crm/static/src/js/tours/crm.js b/addons/crm/static/src/js/tours/crm.js
index 86fba7b7925..12366ffbd7b 100644
--- a/addons/crm/static/src/js/tours/crm.js
+++ b/addons/crm/static/src/js/tours/crm.js
@@ -59,7 +59,7 @@ registry.category("web_tour.tours").add('crm_tour', {
position: "bottom",
width: 200,
}, {
- trigger: '.modal-footer button[name="action_close_dialog"]',
+ trigger: '.modal-footer button[name="action_schedule_activities"]',
content: markup(_t("All set. Let’s Schedule it.")),
position: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
run: function (actions) {
diff --git a/addons/crm/views/crm_menu_views.xml b/addons/crm/views/crm_menu_views.xml
index f27f555a401..3abc031a0b0 100644
--- a/addons/crm/views/crm_menu_views.xml
+++ b/addons/crm/views/crm_menu_views.xml
@@ -127,6 +127,14 @@
parent="crm_menu_config"
action="sales_team.mail_activity_type_action_config_sales"
sequence="10"/>
+
diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js
index 05791ae92dc..a07528a6bc5 100644
--- a/addons/test_mail/static/tests/activity_tests.js
+++ b/addons/test_mail/static/tests/activity_tests.js
@@ -438,18 +438,19 @@ QUnit.module("test_mail", {}, function () {
action.context
);
assert.step("do_action_compose");
- } else if (action.res_model === "mail.activity") {
+ } else if (action.res_model === "mail.activity.schedule") {
assert.deepEqual(
{
default_activity_type_id: mailActivityTypeIds[1],
- default_res_id: mailTestActivityId2,
- default_res_model: "mail.test.activity",
+ active_id: mailTestActivityId2,
+ active_ids: [mailTestActivityId2],
+ active_model: "mail.test.activity",
},
action.context
);
assert.step("do_action_activity");
} else {
- assert.step("Unexpected action");
+ assert.step("Unexpected action" + action.res_model);
}
return Promise.resolve();
},
@@ -621,12 +622,12 @@ QUnit.module("test_mail", {}, function () {
assert.step("doAction");
const expectedAction = {
context: {
- default_res_id: mailTestActivityId1,
- default_res_model: "mail.test.activity",
+ active_ids: [mailTestActivityId1],
+ active_id: mailTestActivityId1,
+ active_model: "mail.test.activity",
},
name: "Schedule Activity",
- res_id: false,
- res_model: "mail.activity",
+ res_model: "mail.activity.schedule",
target: "new",
type: "ir.actions.act_window",
view_mode: "form",
diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py
index f9a178a02ce..0a03cb61b86 100644
--- a/addons/test_mail/tests/__init__.py
+++ b/addons/test_mail/tests/__init__.py
@@ -3,6 +3,7 @@
from . import test_invite
from . import test_ir_actions
from . import test_mail_activity
+from . import test_mail_activity_plan
from . import test_mail_alias
from . import test_mail_composer
from . import test_mail_composer_mixin
diff --git a/addons/test_mail/tests/test_mail_activity_plan.py b/addons/test_mail/tests/test_mail_activity_plan.py
new file mode 100644
index 00000000000..9db491c3f67
--- /dev/null
+++ b/addons/test_mail/tests/test_mail_activity_plan.py
@@ -0,0 +1,295 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date, timedelta
+from freezegun import freeze_time
+
+from odoo import Command, fields
+from odoo.addons.mail.tests.test_mail_activity import ActivityScheduleCase
+from odoo.exceptions import ValidationError
+from odoo.tests import Form, tagged, users
+
+
+@tagged('mail_activity', 'mail_activity_plan')
+class TestActivitySchedule(ActivityScheduleCase):
+ """ Test plan and activity schedule
+
+ - activity scheduling on a single record and in batch
+ - plan scheduling on a single record and in batch
+ - plan creation and consistency
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # prepare plans
+ cls.plan_party = cls.env['mail.activity.plan'].create({
+ 'name': 'Test Plan A Party',
+ 'res_model': 'mail.test.activity',
+ 'template_ids': [
+ Command.create({
+ 'activity_type_id': cls.activity_type_todo.id,
+ 'responsible_type': 'on_demand',
+ 'sequence': 10,
+ 'summary': 'Book a place',
+ }), Command.create({
+ 'activity_type_id': cls.activity_type_todo.id,
+ 'responsible_id': cls.user_admin.id,
+ 'responsible_type': 'other',
+ 'sequence': 20,
+ 'summary': 'Invite special guest',
+ }),
+ ],
+ })
+ cls.plan_onboarding = cls.env['mail.activity.plan'].create({
+ 'name': 'Test Onboarding',
+ 'res_model': 'mail.test.activity',
+ 'template_ids': [
+ Command.create({
+ 'activity_type_id': cls.activity_type_todo.id,
+ 'responsible_id': cls.user_admin.id,
+ 'responsible_type': 'other',
+ 'sequence': 10,
+ 'summary': 'Plan training',
+ }), Command.create({
+ 'activity_type_id': cls.activity_type_todo.id,
+ 'responsible_id': cls.user_admin.id,
+ 'responsible_type': 'other',
+ 'sequence': 20,
+ 'summary': 'Training',
+ }),
+ ]
+ })
+
+ # test records
+ cls.reference_now = fields.Datetime.from_string('2023-09-30 14:00:00')
+ cls.test_records = cls.env['mail.test.activity'].create([
+ {
+ 'date': cls.reference_now + timedelta(days=(idx - 10)),
+ 'email_from': f'customer.activity.{idx}@test.example.com',
+ 'name': f'test_record_{idx}'
+ } for idx in range(5)
+ ])
+
+ @users('employee')
+ def test_activity_schedule(self):
+ """ Test schedule of an activity on a single or multiple records. """
+ test_records_all = [self.test_records[0], self.test_records[:3]]
+ for test_idx, test_case in enumerate(['mono', 'multi']):
+ test_records = test_records_all[test_idx].with_env(self.env)
+ with self.subTest(test_case=test_case, test_records=test_records):
+ # 1. SCHEDULE ACTIVITIES
+ with freeze_time(self.reference_now):
+ form = self._instantiate_activity_schedule_wizard(test_records)
+ form.summary = 'Write specification'
+ form.note = '
Useful link ...
'
+ form.activity_user_id = self.user_admin
+ with self._mock_activities():
+ form.save().action_schedule_activities()
+
+ for record in test_records:
+ self.assertActivityCreatedOnRecord(record, {
+ 'activity_type_id': self.activity_type_todo,
+ 'date_deadline': self.reference_now.date() + timedelta(days=4), # activity type delay
+ 'note': 'Useful link ...
',
+ 'summary': 'Write specification',
+ 'user_id': self.user_admin,
+ })
+
+ # 2. LOG DONE ACTIVITIES
+ with freeze_time(self.reference_now):
+ form = self._instantiate_activity_schedule_wizard(test_records)
+ form.activity_type_id = self.activity_type_call
+ with self._mock_activities(), freeze_time(self.reference_now):
+ form.save().with_context(
+ mail_activity_quick_update=True
+ ).action_schedule_activities_done()
+
+ for record in test_records:
+ self.assertActivityDoneOnRecord(record, self.activity_type_call)
+
+ # 3. LOG DONE ACTIVITIES, PREPARE SCHEDULE NEXT
+ with freeze_time(self.reference_now):
+ form = self._instantiate_activity_schedule_wizard(test_records)
+ form.activity_type_id = self.activity_type_todo
+ with self._mock_activities():
+ wizard_res = form.save().with_context(
+ mail_activity_quick_update=True
+ ).action_schedule_activities_done_and_schedule()
+ self.assertDictEqual(wizard_res, {
+ 'name': "Schedule Activity On Selected Records" if len(test_records) > 1 else "Schedule Activity",
+ 'context': {
+ 'active_id': test_records[0].id,
+ 'active_ids': test_records.ids,
+ 'active_model': test_records._name,
+ 'mail_activity_quick_update': True,
+ 'default_previous_activity_type_id': 4,
+ 'activity_previous_deadline': self.reference_now.date() + timedelta(days=4),
+ 'default_res_ids': repr(test_records.ids),
+ 'default_res_model': test_records._name,
+ },
+ 'view_mode': 'form',
+ 'res_model': 'mail.activity.schedule',
+ 'views': [(False, 'form')],
+ 'type': 'ir.actions.act_window',
+ 'target': 'new',
+ })
+ for record in test_records:
+ self.assertActivityDoneOnRecord(record, self.activity_type_todo)
+
+ # 4. CONTINUE WITH SCHEDULE ACTIVITIES
+ # implies deadline addition on top of previous activities
+ with freeze_time(self.reference_now):
+ form = Form(self.env['mail.activity.schedule'].with_context(wizard_res['context']))
+ form.activity_type_id = self.activity_type_call
+ with self._mock_activities():
+ form.save().with_context(
+ mail_activity_quick_update=True
+ ).action_schedule_activities()
+
+ for record in test_records:
+ self.assertActivityCreatedOnRecord(record, {
+ 'activity_type_id': self.activity_type_call,
+ 'date_deadline': self.reference_now.date() + timedelta(days=5), # both types delays
+ 'note': False,
+ 'summary': False,
+ 'user_id': self.env.user,
+ })
+
+ # global activity creation from tests
+ self.assertEqual(len(self.test_records[0].activity_ids), 4)
+ self.assertEqual(len(self.test_records[1].activity_ids), 2)
+ self.assertEqual(len(self.test_records[2].activity_ids), 2)
+ self.assertEqual(len(self.test_records[3].activity_ids), 0)
+ self.assertEqual(len(self.test_records[4].activity_ids), 0)
+
+ @users('employee')
+ def test_plan_mode(self):
+ """ Test the plan_mode that allows to preselect a compatible plan. """
+ test_record = self.test_records[0].with_env(self.env)
+ context = {
+ 'active_id': test_record.id,
+ 'active_ids': test_record.ids,
+ 'active_model': test_record._name
+ }
+ plan_mode_context = {**context, 'plan_mode': True}
+
+ with Form(self.env['mail.activity.schedule'].with_context(context)) as form:
+ self.assertFalse(form.plan_id)
+ with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form:
+ self.assertEqual(form.plan_id, self.plan_party)
+ # should select only model-plans
+ self.plan_party.res_model = 'res.partner'
+ with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form:
+ self.assertEqual(form.plan_id, self.plan_onboarding)
+
+ @users('employee')
+ def test_plan_schedule(self):
+ """ Test schedule of a plan on a single or multiple records. """
+ test_records_all = [self.test_records[0], self.test_records[:3]]
+ for test_idx, test_case in enumerate(['mono', 'multi']):
+ test_records = test_records_all[test_idx].with_env(self.env)
+ with self.subTest(test_case=test_case, test_records=test_records), \
+ freeze_time(self.reference_now):
+ # No date_deadline specified, No responsible specified
+ form = self._instantiate_activity_schedule_wizard(test_records)
+ self.assertFalse(form.plan_assignation_summary)
+ form.plan_id = self.plan_onboarding
+ self.assertEqual(form.plan_assignation_summary,
+ '- To-Do - Other: Plan training
- To-Do - Other: Training
')
+ self.assertTrue(form._get_modifier('plan_on_demand_user_id', 'invisible'))
+ form.plan_id = self.plan_party
+ self.assertIn('Book a place', form.plan_assignation_summary)
+ self.assertFalse(form._get_modifier('plan_on_demand_user_id', 'invisible'))
+ with self._mock_activities():
+ form.save().action_schedule_plan()
+
+ self.assertPlanExecution(self.plan_party, test_records)
+
+ # date_deadline specified, responsible specified
+ force_date_deadline = date(2050, 1, 15)
+ force_responsible_id = self.user_admin
+ form = self._instantiate_activity_schedule_wizard(test_records)
+ form.plan_id = self.plan_party
+ form.plan_date_deadline = force_date_deadline
+ form.plan_on_demand_user_id = self.env['res.users']
+ self.assertTrue(form.has_error)
+ self.assertIn(f'No responsible specified for {self.activity_type_todo.name}: Book a place',
+ form.error)
+ form.plan_on_demand_user_id = force_responsible_id
+ self.assertFalse(form.has_error)
+ with self._mock_activities():
+ form.save().action_schedule_plan()
+
+ self.assertPlanExecution(
+ self.plan_party, test_records,
+ force_date_deadline=force_date_deadline,
+ force_responsible_id=force_responsible_id)
+
+ @users('admin')
+ def test_plan_setup_model_consistency(self):
+ """ Test the model consistency of a plan.
+
+ Model consistency between activity_type - activity_template - plan:
+ - a plan is restricted to a model
+ - a plan contains activity plan templates which can be limited to some model
+ through activity type
+ """
+ # Setup independent activities type to avoid interference with existing data
+ activity_type_1, activity_type_2, activity_type_3 = self.env['mail.activity.type'].create([
+ {'name': 'Todo'},
+ {'name': 'Call'},
+ {'name': 'Partner-specific', 'res_model': 'res.partner'},
+ ])
+ test_plan = self.env['mail.activity.plan'].create({
+ 'name': 'Test Plan',
+ 'res_model': 'mail.test.activity',
+ 'template_ids': [
+ (0, 0, {'activity_type_id': activity_type_1.id}),
+ (0, 0, {'activity_type_id': activity_type_2.id})
+ ],
+ })
+
+ # ok, all activities generic
+ test_plan.res_model = 'res.partner'
+ test_plan.res_model = 'mail.test.activity'
+
+ with self.assertRaises(
+ ValidationError,
+ msg='Cannot set activity type to res.partner as linked to a plan of another model'):
+ activity_type_1.res_model = 'res.partner'
+
+ activity_type_1.res_model = 'mail.test.activity'
+ with self.assertRaises(
+ ValidationError,
+ msg='Cannot set plan to res.partner as using activities linked to another model'):
+ test_plan.res_model = 'res.partner'
+
+ with self.assertRaises(
+ ValidationError,
+ msg='Cannot create activity template for res.partner as linked to a plan of another model'):
+ self.env['mail.activity.plan.template'].create({
+ 'activity_type_id': activity_type_3.id,
+ 'plan_id': test_plan.id,
+ })
+
+ @users('admin')
+ def test_plan_setup_validation(self):
+ """ Test plan consistency. """
+ plan = self.env['mail.activity.plan'].create({
+ 'name': 'test',
+ 'res_model': 'mail.test.activity',
+ })
+ template = self.env['mail.activity.plan.template'].create({
+ 'activity_type_id': self.activity_type_todo.id,
+ 'plan_id': plan.id,
+ 'responsible_type': 'other',
+ 'responsible_id': self.user_admin.id,
+ })
+ template.responsible_type = 'on_demand'
+ self.assertFalse(template.responsible_id)
+ with self.assertRaises(
+ ValidationError, msg='When selecting responsible "other", you must specify a responsible.'):
+ template.responsible_type = 'other'
+ template.write({'responsible_type': 'other', 'responsible_id': self.user_admin})
diff --git a/addons/web/tooling/_jsconfig.json b/addons/web/tooling/_jsconfig.json
index f0b722f4950..074dee89b45 100644
--- a/addons/web/tooling/_jsconfig.json
+++ b/addons/web/tooling/_jsconfig.json
@@ -6,7 +6,7 @@
"noEmit": true,
"disableSizeLimit": true,
"typeRoots": [
- "addons/mail/tooling/types",
+ "addons/mail/static/src/js/tooling/types",
"addons/web/tooling/types"
],
"paths": {