From 35d5342887f14d7863fd362cff2657fb84e00fe8 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Thu, 6 Jul 2023 15:30:30 +0200 Subject: [PATCH] [IMP] {test_}mail, various: generalize activity plan + activity batch schedule Generalize the hr.employee activity plan to any model, allowing to create activity plan that can be launched on any model. The activity can now be created in batch by selecting multiple record in the list view and then clicking on a "clock" icon in a row similarly to the batch records update (selecting multiple records allows to change for example their name in batch in the list view). We implement also that functionality for the plan, allowing to launch a plan on multiple records at once. We also centralize the launching of activity or activity plan through either the "Activities" button in the chatter or the "clock" button in the view list. Scheduling activity is now taken in charge by a wizard that can schedule a single activity as well as a plan. We add/update tests for checking that the wizard is launched with the right record selection (on a single record or a batch) and add tests for checking the wizard itself. The plan can be defined through an added menu in the technical admin menu but also through configuration menu added in the module crm, project. Technical notes: In the added wizard, to determine that there is a error, we introduce the has_error field because we cannot use easily the error field for that. Indeed, to determine that there is no error, we have to compare it to "


" (more precisely <p><br></p>) which is not handy and may change in the future. This is because when we write False on field error and read it after, we get "


". Instead, we centralize this weird comparison in the model in the compute method of has_error. The ActivityListPopover still displays the activity of the record on which it has been triggered but the button to schedule activity will launch a wizard that create activities in batch for the selected records if more than one was selected. For that, the ActivityListPopover component receives now an additional prop: resIds (selected records) on top of the resId prop (record on which the popup has been triggered). Note that when the line that trigger the popup is not selected, the batch mode is disabled to avoid confusion. Test are updated because the wizard is opened instead of the activity form to schedule a new activity and as the form view is only used to edit already existing activities, default_res_id and default_res_model are no longer passed. We defines date_deadline and date_plan_deadline in the schedule wizard because those date are managed differently (default value, required or not, ...). Task-3390865 Part-of: odoo/odoo#137969 --- addons/calendar/__manifest__.py | 1 + addons/calendar/wizard/__init__.py | 1 + .../calendar/wizard/mail_activity_schedule.py | 19 + .../wizard/mail_activity_schedule_views.xml | 31 ++ addons/crm/__manifest__.py | 1 + addons/crm/security/crm_security.xml | 16 + addons/crm/security/ir.model.access.csv | 2 + addons/crm/static/src/js/tours/crm.js | 2 +- addons/crm/views/crm_menu_views.xml | 8 + addons/crm/views/mail_activity_plan_views.xml | 18 + addons/mail/__manifest__.py | 3 + addons/mail/models/__init__.py | 2 + addons/mail/models/mail_activity.py | 1 + addons/mail/models/mail_activity_plan.py | 74 ++++ .../models/mail_activity_plan_template.py | 109 ++++++ addons/mail/models/mail_activity_type.py | 5 + addons/mail/security/ir.model.access.csv | 8 + addons/mail/security/mail_security.xml | 14 + addons/mail/static/src/core/web/activity.js | 4 +- .../static/src/core/web/activity_button.js | 20 +- .../src/core/web/activity_list_popover.js | 5 +- .../src/core/web/activity_list_popover.xml | 3 +- .../core/web/activity_list_popover_item.js | 6 +- .../static/src/core/web/activity_service.js | 42 ++- addons/mail/static/src/core/web/chatter.js | 2 +- .../src/js}/tooling/types/services.d.ts | 0 .../views/web/activity/activity_controller.js | 2 +- .../static/tests/activity/activity_tests.js | 36 +- .../web/activity/activity_widget_tests.js | 110 +++++- addons/mail/static/tests/web/chatter_tests.js | 29 +- addons/mail/tests/test_mail_activity.py | 145 +++++++- addons/mail/tools/__init__.py | 4 +- .../mail/tools/{credentials.py => discuss.py} | 0 .../mail_activity_plan_template_views.xml | 36 ++ .../mail/views/mail_activity_plan_views.xml | 99 ++++++ addons/mail/views/mail_activity_views.xml | 22 ++ addons/mail/views/mail_menus.xml | 5 + addons/mail/wizard/__init__.py | 1 + addons/mail/wizard/mail_activity_schedule.py | 327 ++++++++++++++++++ .../wizard/mail_activity_schedule_views.xml | 78 +++++ addons/project/__manifest__.py | 1 + addons/project/security/ir.model.access.csv | 2 + addons/project/security/project_security.xml | 17 + .../views/mail_activity_plan_views.xml | 18 + addons/project/views/project_menus.xml | 5 + .../test_mail/static/tests/activity_tests.js | 17 +- addons/test_mail/tests/__init__.py | 1 + .../tests/test_mail_activity_plan.py | 295 ++++++++++++++++ addons/web/tooling/_jsconfig.json | 2 +- 49 files changed, 1600 insertions(+), 49 deletions(-) create mode 100644 addons/calendar/wizard/mail_activity_schedule.py create mode 100644 addons/calendar/wizard/mail_activity_schedule_views.xml create mode 100644 addons/crm/views/mail_activity_plan_views.xml create mode 100644 addons/mail/models/mail_activity_plan.py create mode 100644 addons/mail/models/mail_activity_plan_template.py rename addons/mail/{ => static/src/js}/tooling/types/services.d.ts (100%) rename addons/mail/tools/{credentials.py => discuss.py} (100%) create mode 100644 addons/mail/views/mail_activity_plan_template_views.xml create mode 100644 addons/mail/views/mail_activity_plan_views.xml create mode 100644 addons/mail/wizard/mail_activity_schedule.py create mode 100644 addons/mail/wizard/mail_activity_schedule_views.xml create mode 100644 addons/project/views/mail_activity_plan_views.xml create mode 100644 addons/test_mail/tests/test_mail_activity_plan.py 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/mail/static/src/core/web/activity_list_popover_item.js b/addons/mail/static/src/core/web/activity_list_popover_item.js index 59220b4181b..e0e74db1b18 100644 --- a/addons/mail/static/src/core/web/activity_list_popover_item.js +++ b/addons/mail/static/src/core/web/activity_list_popover_item.js @@ -78,11 +78,7 @@ export class ActivityListPopoverItem extends Component { onClickEditActivityButton() { this.props.onClickEditActivityButton(); this.env.services["mail.activity"] - .schedule( - this.props.activity.res_model, - this.props.activity.res_id, - this.props.activity.id - ) + .edit(this.props.activity.id) .then(() => this.props.onActivityChanged()); } diff --git a/addons/mail/static/src/core/web/activity_service.js b/addons/mail/static/src/core/web/activity_service.js index 8437408e676..12ac3e61f10 100644 --- a/addons/mail/static/src/core/web/activity_service.js +++ b/addons/mail/static/src/core/web/activity_service.js @@ -52,15 +52,8 @@ export class ActivityService { return action; } - async schedule(resModel, resId, activityId = false, defaultActivityTypeId = undefined) { - const context = { - default_res_model: resModel, - default_res_id: resId, - }; - if (defaultActivityTypeId !== undefined) { - context.default_activity_type_id = defaultActivityTypeId; - } - return new Promise((resolve) => { + async edit(activityId){ + return new Promise((resolve) => this.env.services.action.doAction( { type: "ir.actions.act_window", @@ -69,12 +62,37 @@ export class ActivityService { view_mode: "form", views: [[false, "form"]], target: "new", - context, res_id: activityId, }, { onClose: resolve } - ); - }); + ) + ); + } + + async schedule(resModel, resIds, defaultActivityTypeId = undefined) { + const context = { + active_model: resModel, + active_ids: resIds, + active_id: resIds[0], + ...((defaultActivityTypeId !== undefined) ? { + default_activity_type_id: defaultActivityTypeId, + } : {}), + }; + return new Promise((resolve) => + this.env.services.action.doAction( + { + type: "ir.actions.act_window", + name: (resIds && resIds.length > 1) ? _t("Schedule Activity On Selected Records") : + _t("Schedule Activity"), + res_model: "mail.activity.schedule", + view_mode: "form", + views: [[false, "form"]], + target: "new", + context, + }, + { onClose: resolve } + ) + ); } delete(activity, { broadcast = true } = {}) { diff --git a/addons/mail/static/src/core/web/chatter.js b/addons/mail/static/src/core/web/chatter.js index 087520819e0..b159bf46827 100644 --- a/addons/mail/static/src/core/web/chatter.js +++ b/addons/mail/static/src/core/web/chatter.js @@ -377,7 +377,7 @@ export class Chatter extends Component { async scheduleActivity() { this.closeSearch(); const schedule = async (threadId) => { - await this.activityService.schedule(this.props.threadModel, threadId); + await this.activityService.schedule(this.props.threadModel, [threadId]); this.load(this.props.threadId, ["activities", "messages"]); }; if (this.props.threadId) { diff --git a/addons/mail/tooling/types/services.d.ts b/addons/mail/static/src/js/tooling/types/services.d.ts similarity index 100% rename from addons/mail/tooling/types/services.d.ts rename to addons/mail/static/src/js/tooling/types/services.d.ts diff --git a/addons/mail/static/src/views/web/activity/activity_controller.js b/addons/mail/static/src/views/web/activity/activity_controller.js index 129f214e7d6..f90f476e1bb 100644 --- a/addons/mail/static/src/views/web/activity/activity_controller.js +++ b/addons/mail/static/src/views/web/activity/activity_controller.js @@ -63,7 +63,7 @@ export class ActivityController extends Component { multiSelect: false, context: this.props.context, onSelected: async (resIds) => { - await this.activity.schedule(this.props.resModel, resIds[0]); + await this.activity.schedule(this.props.resModel, resIds); this.model.load(); }, }); diff --git a/addons/mail/static/tests/activity/activity_tests.js b/addons/mail/static/tests/activity/activity_tests.js index 34957a01f16..29a8804077f 100644 --- a/addons/mail/static/tests/activity/activity_tests.js +++ b/addons/mail/static/tests/activity/activity_tests.js @@ -413,8 +413,6 @@ QUnit.test("activity click on edit", async (assert) => { patchWithCleanup(env.services.action, { doAction(action) { assert.step("do_action"); - assert.strictEqual(action.context.default_res_id, partnerId); - assert.strictEqual(action.context.default_res_model, "res.partner"); assert.strictEqual(action.type, "ir.actions.act_window"); assert.strictEqual(action.res_model, "mail.activity"); assert.strictEqual(action.res_id, activityId); @@ -522,3 +520,37 @@ QUnit.test("Activity are sorted by deadline", async () => { await contains(":nth-child(2 of .o-mail-Activity)", { text: "Today:" }); await contains(":nth-child(3 of .o-mail-Activity)", { text: "Due in 4 days:" }); }); + +QUnit.test("chatter 'activities' button open the activity schedule wizard", async (assert) => { + const pyEnv = await startServer(); + const fakeId = pyEnv["res.partner"].create({}); + const { env, openFormView } = await start({ serverData: { views } }); + await openFormView("res.partner", fakeId); + patchWithCleanup(env.services.action, { + doAction(action, options) { + assert.step("doAction"); + var expectedAction = { + context: { + active_ids: [fakeId], + active_id: fakeId, + active_model: "res.partner", + }, + name: "Schedule Activity", + res_model: "mail.activity.schedule", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + views: [[false, "form"]], + }; + assert.deepEqual( + action, + expectedAction, + "should execute an action with correct params" + ); + options.onClose(); + return Promise.resolve(); + }, + }); + await click("button", { text: "Activities" }); + assert.verifySteps(["doAction"]); +}); diff --git a/addons/mail/static/tests/web/activity/activity_widget_tests.js b/addons/mail/static/tests/web/activity/activity_widget_tests.js index e4cabb6f18f..735f01e08dc 100644 --- a/addons/mail/static/tests/web/activity/activity_widget_tests.js +++ b/addons/mail/static/tests/web/activity/activity_widget_tests.js @@ -7,7 +7,7 @@ import { ROUTES_TO_IGNORE } from "@mail/../tests/helpers/webclient_setup"; import { serializeDate } from "@web/core/l10n/dates"; import { ListController } from "@web/views/list/list_controller"; -import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils"; import { click, contains } from "@web/../tests/utils"; const { DateTime } = luxon; @@ -238,6 +238,114 @@ QUnit.test("list activity widget: open dropdown", async (assert) => { assert.verifySteps(["web_search_read", "activity_format", "action_feedback", "web_read"]); }); +QUnit.test("list activity widget: batch selection from list", async (assert) => { + function selectContaining(domElement, selector, containing) { + return Array.from(domElement.querySelectorAll(selector)).filter((sel) => sel.textContent.includes(containing)); + } + + const pyEnv = await startServer(); + const [marioId, matildeId, alexanderId] = pyEnv["res.partner"].create([ + { name: "Mario" }, { name: "Matilde" }, { name: "Alexander" }]); + const views = { + "res.partner,false,list": ` + + + + `, + }; + const { env, openView } = await start({ + serverData: { views }, + }); + let scheduleWizardContext = null; + let wizardOpened = makeDeferred(); + patchWithCleanup(env.services.action, { + doAction(action, options) { + if (action.res_model === "mail.activity.schedule") { + scheduleWizardContext = action.context; + assert.step("do_action_activity"); + options.onClose(); + wizardOpened.resolve(); + return true; + } + return super.doAction(action); + }, + }); + await openView({ + res_model: "res.partner", + views: [[false, "list"]], + }); + const marioRow = selectContaining(document.body, '.o_data_row', 'Mario')[0]; + const matildeRow = selectContaining(document.body, '.o_data_row', 'Matilde')[0]; + const alexanderRow = selectContaining(document.body, '.o_data_row', 'Alexander')[0]; + assert.ok(marioRow); + assert.ok(matildeRow); + assert.ok(alexanderRow); + + // Clicking on the clock of a partner without selection, open the wizard for that record only + await click(".o-mail-ActivityButton", { target: matildeRow }); + await click('.o-mail-ActivityListPopover button'); + await wizardOpened; + assert.deepEqual( + scheduleWizardContext, + { + active_ids: [matildeId], + active_id: matildeId, + active_model: "res.partner", + } + ); + + // We select 2 among the 3 partners created above and click on the clock of one of them + await click('.o_list_record_selector .o-checkbox', { target: matildeRow }); + await click('.o_list_record_selector .o-checkbox', { target: marioRow }); + await contains(".o_list_selection_box", { text: "2 selected" }); + await click(".o-mail-ActivityButton", { target: matildeRow }); + await contains(".o-mail-ActivityListPopover button", { text: 'Schedule an activity on selected records' }); + assert.equal(document.body.querySelector(".o-mail-ActivityListPopover button").textContent, + 'Schedule an activity on selected records'); + await click('.o-mail-ActivityListPopover button'); + await wizardOpened; + assert.deepEqual( + scheduleWizardContext, + { + active_ids: [marioId, matildeId], + active_id: marioId, + active_model: "res.partner", + } + ); + // But when clicking on the clock of one of the non-selected row, it applies to only that row + wizardOpened = makeDeferred(); + await click(".o-mail-ActivityButton", { target: alexanderRow }); + await contains(".o-mail-ActivityListPopover button", { text: "Schedule an activity" }); + // await contains(".o-mail-ActivityListPopover button:not(:contains('Schedule an activity on selected records'))"); + await click('.o-mail-ActivityListPopover button'); + await wizardOpened; + assert.deepEqual( + scheduleWizardContext, + { + active_ids: [alexanderId], + active_id: alexanderId, + active_model: "res.partner", + } + ); + // We now check that when clicking on the clock of the other selected row, it applies to both row + wizardOpened = makeDeferred(); + await click(".o-mail-ActivityButton", { target: marioRow }); + await contains(".o-mail-ActivityListPopover", { text: 'Schedule an activity on selected records' }); + assert.equal(document.body.querySelector(".o-mail-ActivityListPopover button").textContent, + 'Schedule an activity on selected records'); + await click('.o-mail-ActivityListPopover button'); + await wizardOpened; + assert.deepEqual( + scheduleWizardContext, + { + active_ids: [marioId, matildeId], + active_id: marioId, + active_model: "res.partner", + } + ); + assert.verifySteps(Array(4).fill('do_action_activity')); +}); + QUnit.test("list activity exception widget with activity", async () => { const pyEnv = await startServer(); const [activityTypeId_1, activityTypeId_2] = pyEnv["mail.activity.type"].create([{}, {}]); diff --git a/addons/mail/static/tests/web/chatter_tests.js b/addons/mail/static/tests/web/chatter_tests.js index 656c6914f64..0f8fe8a0990 100644 --- a/addons/mail/static/tests/web/chatter_tests.js +++ b/addons/mail/static/tests/web/chatter_tests.js @@ -6,7 +6,7 @@ import { DELAY_FOR_SPINNER } from "@mail/core/web/chatter"; import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size"; import { start } from "@mail/../tests/helpers/test_utils"; -import { triggerHotkey } from "@web/../tests/helpers/utils"; +import {makeDeferred, patchWithCleanup, triggerHotkey} from "@web/../tests/helpers/utils"; import { click, contains, @@ -739,7 +739,7 @@ QUnit.test("post message on draft record", async () => { QUnit.test( "schedule activities on draft record should prompt with scheduling an activity (proceed with action)", - async () => { + async (assert) => { const views = { "res.partner,false,form": `
@@ -751,10 +751,29 @@ QUnit.test(
`, }; - const { openFormView } = await start({ serverData: { views } }); - openFormView("res.partner"); + const { env, openFormView } = await start({ serverData: { views } }); + const wizardOpened = makeDeferred(); + patchWithCleanup(env.services.action, { + doAction(action, options) { + if (action.res_model === 'res.partner') { + return super.doAction(action, options); + } else if (action.res_model === 'mail.activity.schedule') { + assert.step("mail.activity.schedule"); + assert.equal(action.context.active_model, 'res.partner'); + assert.ok(Number(action.context.active_id)); + options.onClose(); + wizardOpened.resolve(); + } else { + assert.step("Unexpected action" + action.res_model); + } + }, + }); + await openFormView("res.partner"); await click("button", { text: "Activities" }); - await contains(".o_dialog h4", { text: "Schedule Activity" }); + await wizardOpened; + assert.verifySteps([ + "mail.activity.schedule", + ]); } ); diff --git a/addons/mail/tests/test_mail_activity.py b/addons/mail/tests/test_mail_activity.py index bf31d78f06d..dfa21449607 100644 --- a/addons/mail/tests/test_mail_activity.py +++ b/addons/mail/tests/test_mail_activity.py @@ -1,6 +1,149 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.tests.common import tagged, HttpCase +from contextlib import contextmanager +from datetime import timedelta +from unittest.mock import patch + +from odoo import fields +from odoo.addons.mail.models.mail_activity import MailActivity +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests.common import Form, tagged, HttpCase + + +class ActivityScheduleCase(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # prepare activities + cls.activity_type_todo = cls.env.ref('mail.mail_activity_data_todo') + cls.activity_type_todo.delay_count = 4 + cls.activity_type_call = cls.env.ref('mail.mail_activity_data_call') + cls.activity_type_call.delay_count = 1 + + def reverse_record_set(self, records): + """ Get an equivalent recordset but with elements in reversed order. """ + return self.env[records._name].browse([record.id for record in reversed(records)]) + + def get_last_activities(self, on_record, limit=None): + """ Get the last activities on the record in id asc order. """ + return self.reverse_record_set(self.env['mail.activity'].search( + [('res_model', '=', on_record._name), ('res_id', '=', on_record.id)], order='id desc', limit=limit)) + + # ------------------------------------------------------------ + # ACTIVITIES MOCK + # ------------------------------------------------------------ + + @contextmanager + def _mock_activities(self): + activity_create_origin = MailActivity.create + self._new_activities = self.env['mail.activity'].sudo() + + def _activity_create(model, *args, **kwargs): + res = activity_create_origin(model, *args, **kwargs) + self._new_activities += res.sudo() + return res + + with patch.object( + MailActivity, 'create', autospec=True, wraps=MailActivity, + side_effect=_activity_create + ) as activity_create_mocked: + self.activity_create_mocked = activity_create_mocked + yield + + def assertActivityCreatedOnRecord(self, record, activity_values): + activity = self._new_activities.filtered( + lambda act: act.res_model == record._name and act.res_id == record.id + ) + for fname, fvalue in activity_values.items(): + with self.subTest(fname=fname): + self.assertEqual(activity[fname], fvalue) + + def assertActivityDoneOnRecord(self, record, activity_type): + last_message = record.message_ids[0] + self.assertEqual(last_message.mail_activity_type_id, activity_type) + self.assertIn(activity_type.name, last_message.body) + self.assertIn('done', last_message.body) + + def assertActivitiesFromPlan(self, plan, record, force_date_deadline=None, force_responsible_id=None): + """ Check that the last activities on the record correspond to the one + that the plan must create (number of activities and activities content). + + :param plan: activity plan that has been applied on the record + :param recordset record: record on which the plan has been applied + :param date force_date_deadline: deadline provided when scheduling the plan + :param force_responsible_id: responsible provided when scheduling the plan + """ + expected_number_of_activity = len(plan.template_ids) + activities = self._new_activities.filtered( + lambda act: act.res_model == record._name and act.res_id == record.id + ) + self.assertEqual(len(activities), expected_number_of_activity) + + for activity, template in zip(activities, plan.template_ids): + self.assertEqual(activity.activity_type_id, template.activity_type_id) + if force_date_deadline: + self.assertEqual(activity.date_deadline, force_date_deadline) + else: + self.assertEqual(activity.date_deadline, fields.Date.today() + timedelta(days=template.activity_type_id.delay_count)) + self.assertEqual(activity.note, template.note) + self.assertEqual(activity.summary, template.summary) + if force_responsible_id: + self.assertEqual(activity.user_id, force_responsible_id) + else: + self.assertEqual(activity.user_id, template.responsible_id or self.env.user) + + def assertMessagesFromPlan(self, plan, record, force_date_deadline=None, force_responsible_id=None): + """ Check that the last posted message on the record correspond to the one + that the plan must generate (number of activities and activities content). + + :param plan: activity plan that has been applied on the record + :param recordset record: record on which the plan has been applied + :param date force_date_deadline: deadline provided when scheduling the plan + :param force_responsible_id: responsible provided when scheduling the plan + """ + message = record.message_ids[0] + self.assertIn(f'The plan "{plan.name}" has been started', message.body) + + for template in plan.template_ids: + if force_date_deadline: + date_deadline = force_date_deadline + else: + date_deadline = fields.Date.today() + timedelta(days=template.activity_type_id.delay_count) + if force_responsible_id: + responsible_id = force_responsible_id + else: + responsible_id = template.responsible_id or self.env.user + + self.assertIn(template.summary, message.body) + self.assertIn(f'{template.summary or template.activity_type_id.name}, ' + f'assigned to {responsible_id.name}, due on the {date_deadline}', message.body) + + def assertPlanExecution(self, plan, records, force_date_deadline=None, force_responsible_id=None): + """ Check that the plan has created the right activities and send the + right message on the records (see assertActivitiesFromPlan and + assertMessagesFromPlan). """ + for record in records: + self.assertActivitiesFromPlan( + plan, record, + force_date_deadline=force_date_deadline, + force_responsible_id=force_responsible_id, + ) + self.assertMessagesFromPlan( + plan, record, + force_date_deadline=force_date_deadline, + force_responsible_id=force_responsible_id, + ) + + def _instantiate_activity_schedule_wizard(self, records, additional_context_value=None): + """ Get a new Form with context default values referring to the records. """ + return Form(self.env['mail.activity.schedule'].with_context({ + 'active_id': records.ids[0], + 'active_ids': records.ids, + 'active_model': records._name, + **(additional_context_value if additional_context_value else {}), + })) @tagged("-at_install", "post_install") diff --git a/addons/mail/tools/__init__.py b/addons/mail/tools/__init__.py index 9cbdd418684..d041a3a4197 100644 --- a/addons/mail/tools/__init__.py +++ b/addons/mail/tools/__init__.py @@ -2,5 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import alias_error +from . import discuss +from . import link_preview from . import mail_validation -from . import credentials +from . import parser diff --git a/addons/mail/tools/credentials.py b/addons/mail/tools/discuss.py similarity index 100% rename from addons/mail/tools/credentials.py rename to addons/mail/tools/discuss.py diff --git a/addons/mail/views/mail_activity_plan_template_views.xml b/addons/mail/views/mail_activity_plan_template_views.xml new file mode 100644 index 00000000000..a9d693aa6ce --- /dev/null +++ b/addons/mail/views/mail_activity_plan_template_views.xml @@ -0,0 +1,36 @@ + + + + + mail.activity.plan.template.view.tree + mail.activity.plan.template + + + + + + + + + + + mail.activity.plan.template.view.form + mail.activity.plan.template + +
+ + + + + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/addons/mail/views/mail_activity_plan_views.xml b/addons/mail/views/mail_activity_plan_views.xml new file mode 100644 index 00000000000..1f5fd820435 --- /dev/null +++ b/addons/mail/views/mail_activity_plan_views.xml @@ -0,0 +1,99 @@ + + + + + mail.activity.plan.view.search + mail.activity.plan + + + + + + + + + + + mail.activity.plan.view.tree + mail.activity.plan + + + + + + + + + + + mail.activity.plan.view.form + mail.activity.plan + 20 + +
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + mail.activity.plan.view.form.fixed.model + mail.activity.plan + primary + 10 + + + + + + + + Activity Plans + mail.activity.plan + tree,form + + + + +

+ Add a new plan +

+
+
+ +
+
diff --git a/addons/mail/views/mail_activity_views.xml b/addons/mail/views/mail_activity_views.xml index 1d0ee923d7b..7ec2da2fbcd 100644 --- a/addons/mail/views/mail_activity_views.xml +++ b/addons/mail/views/mail_activity_views.xml @@ -51,6 +51,28 @@
+ + mail.activity.type.form.inherit + mail.activity.type + + primary + 50 + + + chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id + + + chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id + + + chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id + + + chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id + + + + mail.activity.type.search mail.activity.type diff --git a/addons/mail/views/mail_menus.xml b/addons/mail/views/mail_menus.xml index 9f267f3c195..164c1f7577b 100644 --- a/addons/mail/views/mail_menus.xml +++ b/addons/mail/views/mail_menus.xml @@ -90,6 +90,11 @@ parent="mail.mail_menu_technical" sequence="11" /> + 1): + errors.add(_('The records must belong to the same company.')) + if scheduler.plan_id: + errors |= set(scheduler._check_plan_templates_error(applied_on)) + if errors: + error_header = ( + _('The plan "%(plan_name)s" cannot be launched:', plan_name=scheduler.plan_id.name) if scheduler.plan_id + else _('The activity cannot be launched:') + ) + error_body = Markup('
    %s
') % ( + Markup().join(Markup('
  • %s
  • ') % error for error in errors) + ) + scheduler.error = f'{error_header}{error_body}' + scheduler.has_error = True + else: + scheduler.error = False + scheduler.has_error = False + + @api.depends('res_ids') + def _compute_is_batch_mode(self): + for scheduler in self: + scheduler.is_batch_mode = len(scheduler._evaluate_res_ids()) > 1 + + @api.depends('company_id', 'res_model_id') + def _compute_plan_available_ids(self): + for scheduler in self: + scheduler.plan_available_ids = self.env['mail.activity.plan'].search(self._get_plan_available_base_domain()) + + @api.depends_context('plan_mode') + @api.depends('plan_available_ids') + def _compute_plan_id(self): + for scheduler in self: + if self.env.context.get('plan_mode'): + scheduler.plan_id = scheduler.env['mail.activity.plan'].search( + [('id', 'in', self.plan_available_ids.ids)], order='id', limit=1) + else: + scheduler.plan_id = False + + @api.depends('res_model_id', 'res_ids') + def _compute_plan_date_deadline(self): + self.plan_date_deadline = False + + @api.depends('res_model') + def _compute_activity_type_id(self): + for scheduler in self: + if not scheduler.activity_type_id: + scheduler.activity_type_id = scheduler.env['mail.activity']._default_activity_type_for_model(scheduler.res_model) + + @api.depends('activity_type_id') + def _compute_date_deadline(self): + for scheduler in self: + if scheduler.activity_type_id: + scheduler.date_deadline = scheduler.env['mail.activity']._calculate_date_deadline(scheduler.activity_type_id) + elif not scheduler.date_deadline: + scheduler.date_deadline = fields.Date.context_today(scheduler) + + @api.depends('activity_type_id') + def _compute_summary(self): + for scheduler in self: + if scheduler.activity_type_id.summary: + scheduler.summary = scheduler.activity_type_id.summary + + @api.depends('activity_type_id') + def _compute_note(self): + for scheduler in self: + if scheduler.activity_type_id.default_note: + scheduler.note = scheduler.activity_type_id.default_note + + @api.depends('activity_type_id') + def _compute_activity_user_id(self): + for scheduler in self: + if scheduler.activity_type_id.default_user_id: + scheduler.activity_user_id = scheduler.activity_type_id.default_user_id + elif not scheduler.activity_user_id: + scheduler.activity_user_id = self.env.user + + # Any writable fields that can change error computed field + @api.constrains('res_model_id', 'res_ids', # records (-> responsible) + 'plan_id', 'plan_on_demand_user_id', # plan specific + 'activity_type_id', 'activity_user_id') # activity specific + def _check_consistency(self): + for scheduler in self.filtered('error'): + raise ValidationError(scheduler.error) + + @api.constrains('res_ids') + def _check_res_ids(self): + """ Check res_ids is a valid list of integers (or Falsy). """ + for scheduler in self: + scheduler._evaluate_res_ids() + + # ------------------------------------------------------------ + # PLAN-BASED SCHEDULING API + # ------------------------------------------------------------ + + def action_schedule_plan(self): + applied_on = self._get_applied_on_records() + for record in applied_on: + body = _('The plan "%(plan_name)s" has been started', plan_name=self.plan_id.name) + activity_descriptions = [] + for template in self._plan_filter_activity_templates_to_schedule(): + if template.responsible_type == 'on_demand': + responsible = self.plan_on_demand_user_id + else: + responsible = template._determine_responsible(self.plan_on_demand_user_id, record)['responsible'] + date_deadline = self.env['mail.activity']._calculate_date_deadline( + template.activity_type_id) if not self.plan_date_deadline else self.plan_date_deadline + record.activity_schedule( + activity_type_id=template.activity_type_id.id, + summary=template.summary, + note=template.note, + user_id=responsible.id, + date_deadline=date_deadline + ) + activity_descriptions.append( + _('%(activity)s, assigned to %(name)s, due on the %(deadline)s', + activity=template.summary or template.activity_type_id.name, + name=responsible.name, deadline=date_deadline)) + + if activity_descriptions: + body += Markup('
      %s
    ') % ( + Markup().join(Markup('
  • %s
  • ') % description for description in activity_descriptions) + ) + record.message_post(body=body) + + if len(applied_on) == 1: + return { + 'type': 'ir.actions.act_window', + 'res_model': self.res_model, + 'res_id': applied_on.id, + 'name': applied_on.display_name, + 'view_mode': 'form', + 'views': [(False, "form")], + } + + return { + 'type': 'ir.actions.act_window', + 'res_model': self.res_model, + 'name': _('Launch Plans'), + 'view_mode': 'tree,form', + 'target': 'current', + 'domain': [('id', 'in', applied_on.ids)], + } + + def _check_plan_templates_error(self, applied_on): + self.ensure_one() + return filter( + None, [ + activity_template._determine_responsible(self.plan_on_demand_user_id, record)['error'] + for activity_template in self.plan_id.template_ids + for record in applied_on + ] + ) + + # ------------------------------------------------------------ + # ACTIVITY-BASED SCHEDULING API + # ------------------------------------------------------------ + + def action_schedule_activities(self): + self._action_schedule_activities() + + def action_schedule_activities_done(self): + self._action_schedule_activities().action_done() + + def action_schedule_activities_done_and_schedule(self): + ctx = dict( + clean_context(self.env.context), + default_previous_activity_type_id=self.activity_type_id.id, + activity_previous_deadline=self.date_deadline, + default_res_ids=self.res_ids, + default_res_model=self.res_model, + ) + _messages, next_activities = self._action_schedule_activities()._action_done() + if next_activities: + return False + return { + 'name': _("Schedule Activity On Selected Records") if self.is_batch_mode else _("Schedule Activity"), + 'context': ctx, + 'view_mode': 'form', + 'res_model': 'mail.activity.schedule', + 'views': [(False, 'form')], + 'type': 'ir.actions.act_window', + 'target': 'new', + } + + def _action_schedule_activities(self): + return self._get_applied_on_records().activity_schedule( + activity_type_id=self.activity_type_id.id, + summary=self.summary, + note=self.note, + user_id=self.activity_user_id.id, + date_deadline=self.date_deadline + ) + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + def _evaluate_res_ids(self): + """ Parse composer res_ids, which can be: an already valid list or + tuple (generally in code), a list or tuple as a string (coming from + actions). Void strings / missing values are evaluated as an empty list. + + :return: a list of IDs (empty list in case of falsy strings)""" + self.ensure_one() + return parse_res_ids(self.res_ids) or [] + + def _get_applied_on_records(self): + return self.env[self.res_model].browse(self._evaluate_res_ids()) + + def _get_plan_available_base_domain(self): + self.ensure_one() + return [ + '&', '|', ('company_id', '=', False), ('company_id', '=', self.company_id.id), + '|', ('res_model', '=', False), ('res_model', '=', self.res_model) + ] + + def _plan_filter_activity_templates_to_schedule(self): + return self.plan_id.template_ids diff --git a/addons/mail/wizard/mail_activity_schedule_views.xml b/addons/mail/wizard/mail_activity_schedule_views.xml new file mode 100644 index 00000000000..1f2f9c33e7a --- /dev/null +++ b/addons/mail/wizard/mail_activity_schedule_views.xml @@ -0,0 +1,78 @@ + + + + + Activity schedule + mail.activity.schedule + +
    + + + + + + + +
    + + + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + +
    +
    +
    +
    diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 07590c7515e..60366903a66 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -40,6 +40,7 @@ 'views/project_milestone_views.xml', 'views/res_partner_views.xml', 'views/res_config_settings_views.xml', + 'views/mail_activity_plan_views.xml', 'views/mail_activity_type_views.xml', 'views/project_sharing_project_task_views.xml', 'views/project_portal_project_project_templates.xml', diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index a32781be6a1..c94da0fb0cd 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -43,3 +43,5 @@ access_project_collaborator_user,project.collaborator.user,model_project_collabo access_project_collaborator_portal,project.collaborator.portal,model_project_collaborator,base.group_portal,1,0,0,0 access_project_share_manager,project.share.wizard.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0 access_project_personal_stage,project.personal.stage.user,model_project_task_stage_personal,base.group_user,1,1,1,1 +access_mail_activity_plan_project_manager,mail.activity.plan.project.manager,mail.model_mail_activity_plan,project.group_project_manager,1,1,1,1 +access_mail_activity_plan_template_project_manager,mail.activity.plan.template.project.manager,mail.model_mail_activity_plan_template,project.group_project_manager,1,1,1,1 diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 260c9e8c599..09e4854cdcf 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -313,5 +313,22 @@ [(1, '=', 1)]
    + + + Manager can manage task plans + + + [('res_model', '=', 'project.task')] + + + + + Manager can manage task plan templates + + + [('plan_id.res_model', '=', 'project.task')] + + +
    diff --git a/addons/project/views/mail_activity_plan_views.xml b/addons/project/views/mail_activity_plan_views.xml new file mode 100644 index 00000000000..0a38b02cd1e --- /dev/null +++ b/addons/project/views/mail_activity_plan_views.xml @@ -0,0 +1,18 @@ + + + + + Task Plans + mail.activity.plan + tree,form + + {'default_res_model': 'project.task'} + [('res_model', '=', 'project.task')] + +

    + Add a new plan +

    +
    +
    +
    +
    \ No newline at end of file diff --git a/addons/project/views/project_menus.xml b/addons/project/views/project_menus.xml index 7249f6170fb..3deb6abce43 100644 --- a/addons/project/views/project_menus.xml +++ b/addons/project/views/project_menus.xml @@ -108,6 +108,11 @@ id="project_menu_config_activity_type" action="mail_activity_type_action_config_project_types" /> + 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": {