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": {