From 0a66ff9283f077a1dba03bf332752cb3850e6a13 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Fri, 6 Oct 2023 15:22:59 +0200 Subject: [PATCH 1/5] [IMP] mail: move res_ids parser in tools Purpose is to make it more widely available, as it can be used in other wizards dealing with res_ids / active_ids. Task-3390865 Part-of: odoo/odoo#137969 --- addons/mail/models/mail_ice_server.py | 2 +- addons/mail/tools/parser.py | 35 ++++++++++++++++++++++ addons/mail/wizard/mail_compose_message.py | 20 ++----------- 3 files changed, 39 insertions(+), 18 deletions(-) create mode 100644 addons/mail/tools/parser.py diff --git a/addons/mail/models/mail_ice_server.py b/addons/mail/models/mail_ice_server.py index b45c04945b4..b17370cfc4d 100644 --- a/addons/mail/models/mail_ice_server.py +++ b/addons/mail/models/mail_ice_server.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import fields, models -from odoo.addons.mail.tools.credentials import get_twilio_credentials +from odoo.addons.mail.tools.discuss import get_twilio_credentials import requests diff --git a/addons/mail/tools/parser.py b/addons/mail/tools/parser.py new file mode 100644 index 00000000000..9d0d6339452 --- /dev/null +++ b/addons/mail/tools/parser.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import ast + +from odoo import _, tools +from odoo.exceptions import ValidationError + + +def parse_res_ids(res_ids): + """ Returns the already valid list/tuple of int or returns the literal eval + of the string as a list/tuple of int. Void strings / missing values are + evaluated as an empty list. + + :param str|tuple|list res_ids: a list of ids, tuple or list; + + :raise: ValidationError if the provided res_ids is an incorrect type or + invalid format; + + :return list: list of ids + """ + if tools.is_list_of(res_ids, int) or not res_ids: + return res_ids + error_msg = _("Invalid res_ids %(res_ids_str)s (type %(res_ids_type)s)", + res_ids_str=res_ids, + res_ids_type=type(res_ids)) + try: + res_ids = ast.literal_eval(res_ids) + except Exception as e: + raise ValidationError(error_msg) from e + + if not tools.is_list_of(res_ids, int): + raise ValidationError(error_msg) + + return res_ids diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index f5d4272833d..c7a342b9bf3 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -10,6 +10,7 @@ from odoo import _, api, fields, models, tools, Command from odoo.exceptions import UserError, ValidationError from odoo.osv import expression from odoo.tools import is_html_empty +from odoo.addons.mail.tools.parser import parse_res_ids def _reopen(self, res_id, model, context=None): @@ -379,7 +380,7 @@ class MailComposer(models.TransientModel): if composer.parent_id and composer.composition_mode == 'comment': composer.res_ids = f"{[composer.parent_id.res_id]}" else: - active_res_ids = self._parse_res_ids(self.env.context.get('active_ids')) + active_res_ids = parse_res_ids(self.env.context.get('active_ids')) # beware, field is limited in storage, usage of active_ids in context still required if active_res_ids and len(active_res_ids) <= self._batch_size: composer.res_ids = f"{self.env.context['active_ids']}" @@ -1329,27 +1330,12 @@ class MailComposer(models.TransientModel): :return: a list of IDs (empty list in case of falsy strings)""" self.ensure_one() - return self._parse_res_ids( + return parse_res_ids( self.env.context.get('composer_force_res_ids') or self.res_ids or self.env.context.get('active_ids') ) or [] - @api.model - def _parse_res_ids(self, res_ids): - if tools.is_list_of(res_ids, int) or not res_ids: - return res_ids - error_msg = _("Invalid res_ids %(res_ids_str)s (type %(res_ids_type)s)", - res_ids_str=res_ids, - res_ids_type=type(res_ids)) - try: - res_ids = ast.literal_eval(res_ids) - except Exception as e: - raise ValidationError(error_msg) from e - if not tools.is_list_of(res_ids, int): - raise ValidationError(error_msg) - return res_ids - def _set_value_from_template(self, template_fname, composer_fname=False): """ Set composer value from its template counterpart. In monorecord comment mode, we get directly the rendered value, giving the real From ed4f6d3dbeca0c48b839d845c2314d73fd70078c Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Wed, 9 Aug 2023 11:35:54 +0200 Subject: [PATCH 2/5] [MOV] web, mail: move filterable selection We move the filterable widget from loyalty to web to make it more widely accessible to other applications. It will soon be used in the new activities plan feature. Following the move of the filterable selection widget to the web module, we do some light cleanup. Task-3390865 Part-of: odoo/odoo#137969 --- addons/loyalty/__manifest__.py | 4 ---- .../src/views/fields/selection}/filterable_selection_field.js | 0 .../tests/views/fields}/filterable_selection_field_tests.js | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) rename addons/{loyalty/static/src/js/filterable_selection_field => web/static/src/views/fields/selection}/filterable_selection_field.js (100%) rename addons/{loyalty/static/tests/views => web/static/tests/views/fields}/filterable_selection_field_tests.js (98%) diff --git a/addons/loyalty/__manifest__.py b/addons/loyalty/__manifest__.py index 5e120d1211e..54bfed2cd01 100644 --- a/addons/loyalty/__manifest__.py +++ b/addons/loyalty/__manifest__.py @@ -30,12 +30,8 @@ 'loyalty/static/src/js/loyalty_control_panel_widget.js', 'loyalty/static/src/js/loyalty_list_view.js', 'loyalty/static/src/scss/loyalty.scss', - 'loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js', 'loyalty/static/src/xml/loyalty_templates.xml', ], - 'web.qunit_suite_tests': [ - 'loyalty/static/tests/**/*.js', - ], }, 'installable': True, 'license': 'LGPL-3', diff --git a/addons/loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js b/addons/web/static/src/views/fields/selection/filterable_selection_field.js similarity index 100% rename from addons/loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js rename to addons/web/static/src/views/fields/selection/filterable_selection_field.js diff --git a/addons/loyalty/static/tests/views/filterable_selection_field_tests.js b/addons/web/static/tests/views/fields/filterable_selection_field_tests.js similarity index 98% rename from addons/loyalty/static/tests/views/filterable_selection_field_tests.js rename to addons/web/static/tests/views/fields/filterable_selection_field_tests.js index 88a42a860a6..f71ae94ff94 100644 --- a/addons/loyalty/static/tests/views/filterable_selection_field_tests.js +++ b/addons/web/static/tests/views/fields/filterable_selection_field_tests.js @@ -34,7 +34,7 @@ QUnit.module("Fields", (hooks) => { setupViewRegistries(); }); - QUnit.module("Loyalty > FilterableSelectionField"); + QUnit.module("utils"); QUnit.test("FilterableSelectionField test whitelist", async (assert) => { await makeView({ From 35d5342887f14d7863fd362cff2657fb84e00fe8 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Thu, 6 Jul 2023 15:30:30 +0200 Subject: [PATCH 3/5] [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": { From 4d2ac63de6d1c47ea41f29e8a1a452e9cea915ba Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Thu, 28 Sep 2023 16:27:23 +0200 Subject: [PATCH 4/5] [MOV] hr(_contract/_fleet): prepare hr plan conversion to mail plan To keep the git history, we rename the files related to hr plan to their new name before doing the changes in the following commit. Task-3390865 Part-of: odoo/odoo#137969 --- addons/hr/models/{hr_plan.py => mail_activity_plan.py} | 0 .../{hr_plan_activity_type.py => mail_activity_plan_template.py} | 0 .../hr/views/{hr_plan_views.xml => mail_activity_plan_views.xml} | 0 addons/hr/wizard/{hr_plan_wizard.py => mail_activity_schedule.py} | 0 ...{hr_plan_wizard_views.xml => mail_activity_schedule_views.xml} | 0 .../wizard/{hr_plan_wizard.py => mail_activity_schedule.py} | 0 .../{hr_plan_activity_type.py => mail_activity_plan_template.py} | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename addons/hr/models/{hr_plan.py => mail_activity_plan.py} (100%) rename addons/hr/models/{hr_plan_activity_type.py => mail_activity_plan_template.py} (100%) rename addons/hr/views/{hr_plan_views.xml => mail_activity_plan_views.xml} (100%) rename addons/hr/wizard/{hr_plan_wizard.py => mail_activity_schedule.py} (100%) rename addons/hr/wizard/{hr_plan_wizard_views.xml => mail_activity_schedule_views.xml} (100%) rename addons/hr_contract/wizard/{hr_plan_wizard.py => mail_activity_schedule.py} (100%) rename addons/hr_fleet/models/{hr_plan_activity_type.py => mail_activity_plan_template.py} (100%) diff --git a/addons/hr/models/hr_plan.py b/addons/hr/models/mail_activity_plan.py similarity index 100% rename from addons/hr/models/hr_plan.py rename to addons/hr/models/mail_activity_plan.py diff --git a/addons/hr/models/hr_plan_activity_type.py b/addons/hr/models/mail_activity_plan_template.py similarity index 100% rename from addons/hr/models/hr_plan_activity_type.py rename to addons/hr/models/mail_activity_plan_template.py diff --git a/addons/hr/views/hr_plan_views.xml b/addons/hr/views/mail_activity_plan_views.xml similarity index 100% rename from addons/hr/views/hr_plan_views.xml rename to addons/hr/views/mail_activity_plan_views.xml diff --git a/addons/hr/wizard/hr_plan_wizard.py b/addons/hr/wizard/mail_activity_schedule.py similarity index 100% rename from addons/hr/wizard/hr_plan_wizard.py rename to addons/hr/wizard/mail_activity_schedule.py diff --git a/addons/hr/wizard/hr_plan_wizard_views.xml b/addons/hr/wizard/mail_activity_schedule_views.xml similarity index 100% rename from addons/hr/wizard/hr_plan_wizard_views.xml rename to addons/hr/wizard/mail_activity_schedule_views.xml diff --git a/addons/hr_contract/wizard/hr_plan_wizard.py b/addons/hr_contract/wizard/mail_activity_schedule.py similarity index 100% rename from addons/hr_contract/wizard/hr_plan_wizard.py rename to addons/hr_contract/wizard/mail_activity_schedule.py diff --git a/addons/hr_fleet/models/hr_plan_activity_type.py b/addons/hr_fleet/models/mail_activity_plan_template.py similarity index 100% rename from addons/hr_fleet/models/hr_plan_activity_type.py rename to addons/hr_fleet/models/mail_activity_plan_template.py From 7053e37df1d69d3cf7901593e0b00120739dd2b4 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Mon, 14 Aug 2023 10:58:13 +0200 Subject: [PATCH 5/5] [IMP] hr(_contract/_fleet): convert hr.plan to mail.activity.plan We convert the custom plan implementation to use the generic one. As the generic one can define plans for multiple model. We use the check dedicated_to_res_model == 'hr.employee' to activate the specific feature for hr.employee. Indeed, that field contains the model name when the plan is applicable only for one model. We also remove the 'launch plan' button as we can now launch a plan directly from the activities button in the chatter. Technical notes: For the activity schedule wizard, we add the support for active_ids and active_model as default values for res_ids and res_model because it is used as link in the chatter to launch the wizard and we want to avoid a big migration by keeping the link identical (and there are probably no simple solution to keep the same behavior). HR CONTRACT Before the first contract date of the first selected element was chosen to determine the planned due date if all first contract date were different otherwise the minimum was chosen. So if the selection included 2 different date among 3, the minimum was chosen but the first if the 3 were different. With this change, the minimum is always used to determine the default planned due date. Task-3390865 Part-of: odoo/odoo#137969 --- addons/hr/__manifest__.py | 4 +- addons/hr/data/hr_data.xml | 31 ++- addons/hr/models/__init__.py | 5 +- addons/hr/models/hr_department.py | 6 +- addons/hr/models/hr_plan_employee_activity.py | 32 --- addons/hr/models/mail_activity_plan.py | 51 ++-- .../hr/models/mail_activity_plan_template.py | 77 +++--- addons/hr/security/hr_security.xml | 41 ++- addons/hr/security/ir.model.access.csv | 8 +- addons/hr/tests/__init__.py | 1 + addons/hr/tests/test_mail_activity_plan.py | 240 ++++++++++++++++++ addons/hr/views/hr_employee_views.xml | 7 +- addons/hr/views/hr_views.xml | 6 +- addons/hr/views/mail_activity_plan_views.xml | 227 ++++------------- addons/hr/wizard/__init__.py | 2 +- addons/hr/wizard/mail_activity_schedule.py | 148 ++--------- .../wizard/mail_activity_schedule_views.xml | 36 +-- addons/hr_contract/tests/__init__.py | 1 + .../tests/test_mail_activity_plan.py | 58 +++++ addons/hr_contract/wizard/__init__.py | 2 +- .../wizard/mail_activity_schedule.py | 35 ++- addons/hr_fleet/data/hr_fleet_data.xml | 4 +- addons/hr_fleet/data/hr_fleet_demo.xml | 4 +- addons/hr_fleet/models/__init__.py | 2 +- .../models/mail_activity_plan_template.py | 32 ++- addons/hr_fleet/tests/__init__.py | 4 + .../hr_fleet/tests/test_mail_activity_plan.py | 75 ++++++ 27 files changed, 604 insertions(+), 535 deletions(-) delete mode 100644 addons/hr/models/hr_plan_employee_activity.py create mode 100644 addons/hr/tests/test_mail_activity_plan.py create mode 100644 addons/hr_contract/tests/test_mail_activity_plan.py create mode 100644 addons/hr_fleet/tests/__init__.py create mode 100644 addons/hr_fleet/tests/test_mail_activity_plan.py diff --git a/addons/hr/__manifest__.py b/addons/hr/__manifest__.py index e107ca096f6..2c59d18266f 100644 --- a/addons/hr/__manifest__.py +++ b/addons/hr/__manifest__.py @@ -21,12 +21,12 @@ 'data': [ 'security/hr_security.xml', 'security/ir.model.access.csv', - 'wizard/hr_plan_wizard_views.xml', 'wizard/hr_departure_wizard_views.xml', + 'wizard/mail_activity_schedule_views.xml', + 'views/mail_activity_plan_views.xml', 'views/hr_departure_reason_views.xml', 'views/hr_contract_type_views.xml', 'views/hr_job_views.xml', - 'views/hr_plan_views.xml', 'views/hr_employee_category_views.xml', 'views/hr_employee_public_views.xml', 'report/hr_employee_badge.xml', diff --git a/addons/hr/data/hr_data.xml b/addons/hr/data/hr_data.xml index e05352d383e..cdbf5747750 100644 --- a/addons/hr/data/hr_data.xml +++ b/addons/hr/data/hr_data.xml @@ -15,41 +15,48 @@ - + Onboarding + hr.employee - + + 10 Setup IT Materials - manager + manager - + + 20 Plan Training - manager + manager - + + 30 Training - employee + employee - + Offboarding + hr.employee - + + 10 Compute Out Delais - manager + manager - + + 20 Take Back HR Materials - manager + manager diff --git a/addons/hr/models/__init__.py b/addons/hr/models/__init__.py index 3fcdcd3af8a..c970d0c603b 100644 --- a/addons/hr/models/__init__.py +++ b/addons/hr/models/__init__.py @@ -9,10 +9,9 @@ from . import hr_employee_public from . import hr_department from . import hr_departure_reason from . import hr_job -from . import hr_plan -from . import hr_plan_activity_type -from . import hr_plan_employee_activity from . import hr_work_location +from . import mail_activity_plan +from . import mail_activity_plan_template from . import mail_alias from . import discuss_channel from . import models diff --git a/addons/hr/models/hr_department.py b/addons/hr/models/hr_department.py index 2ed7d22cede..3c8e3e7f215 100644 --- a/addons/hr/models/hr_department.py +++ b/addons/hr/models/hr_department.py @@ -23,7 +23,7 @@ class Department(models.Model): member_ids = fields.One2many('hr.employee', 'department_id', string='Members', readonly=True) total_employee = fields.Integer(compute='_compute_total_employee', string='Total Employee') jobs_ids = fields.One2many('hr.job', 'department_id', string='Jobs') - plan_ids = fields.One2many('hr.plan', 'department_id') + plan_ids = fields.One2many('mail.activity.plan', 'department_id') plans_count = fields.Integer(compute='_compute_plan_count') note = fields.Text('Note') color = fields.Integer('Color Index') @@ -63,7 +63,7 @@ class Department(models.Model): department.total_employee = result.get(department.id, 0) def _compute_plan_count(self): - plans_data = self.env['hr.plan']._read_group([('department_id', 'in', self.ids)], ['department_id'], ['__count']) + plans_data = self.env['mail.activity.plan']._read_group([('department_id', 'in', self.ids)], ['department_id'], ['__count']) plans_count = {department.id: count for department, count in plans_data} for department in self: department.plans_count = plans_count.get(department.id, 0) @@ -128,7 +128,7 @@ class Department(models.Model): return res def action_plan_from_department(self): - action = self.env['ir.actions.actions']._for_xml_id('hr.hr_plan_action') + action = self.env['ir.actions.actions']._for_xml_id('hr.mail_activity_plan_action') action['context'] = {'default_department_id': self.id, 'search_default_department_id': self.id} return action diff --git a/addons/hr/models/hr_plan_employee_activity.py b/addons/hr/models/hr_plan_employee_activity.py deleted file mode 100644 index ba4b1599809..00000000000 --- a/addons/hr/models/hr_plan_employee_activity.py +++ /dev/null @@ -1,32 +0,0 @@ -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class HrPlanEmployeeActivity(models.Model): - _name = 'hr.plan.employee.activity' - _description = 'Plan' - _inherit = ['mail.thread', 'mail.activity.mixin'] - _mail_post_access = 'read' - _rec_name = 'employee_id' - - employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade') - department_id = fields.Many2one(related='employee_id.department_id') - image_128 = fields.Image(related='employee_id.image_128') - company_id = fields.Many2one(related='employee_id.company_id') - user_id = fields.Many2one(related='employee_id.user_id') - summary = fields.Html(compute='_compute_summary') - - @api.depends('activity_ids.summary') - def _compute_summary(self): - for plan in self: - plan.summary = self.env['ir.ui.view']._render_template('hr.hr_employee_plan_activity_summary', { - 'activity_ids': plan.activity_ids, - }) - - @api.autovacuum - def _gc_employee_plan_activity(self): - no_activities = self.env['hr.plan.employee.activity'].search( - [('activity_ids', '=', False)] - ) - no_activities.unlink() diff --git a/addons/hr/models/mail_activity_plan.py b/addons/hr/models/mail_activity_plan.py index 1f234226ed9..01d4626c03e 100644 --- a/addons/hr/models/mail_activity_plan.py +++ b/addons/hr/models/mail_activity_plan.py @@ -1,27 +1,38 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.exceptions import UserError -class HrPlan(models.Model): - _name = 'hr.plan' - _description = 'plan' +class MailActivityPlan(models.Model): + _inherit = 'mail.activity.plan' - name = fields.Char('Name', required=True) - company_id = fields.Many2one( - 'res.company', default=lambda self: self.env.company) - department_id = fields.Many2one('hr.department', check_company=True) - plan_activity_type_ids = fields.One2many( - 'hr.plan.activity.type', 'plan_id', - string='Activities', - check_company=True) - active = fields.Boolean(default=True) - steps_count = fields.Integer(compute='_compute_steps_count') + department_id = fields.Many2one( + 'hr.department', check_company=True, + compute='_compute_department_id', ondelete='cascade', readonly=False, store=True) - @api.depends('plan_activity_type_ids') - def _compute_steps_count(self): - activity_type_data = self.env['hr.plan.activity.type']._read_group([('plan_id', 'in', self.ids)], ['plan_id'], ['__count']) - steps_count = {plan.id: count for plan, count in activity_type_data} - for plan in self: - plan.steps_count = steps_count.get(plan.id, 0) + @api.constrains('res_model') + def _check_compatibility_with_model(self): + """ Check that when the model is updated to a model different from employee, + there are no remaining specific values to employee. """ + plan_tocheck = self.filtered(lambda plan: plan.res_model != 'hr.employee') + failing_plans = plan_tocheck.filtered('department_id') + if failing_plans: + raise UserError( + _('Plan %(plan_names)s cannot use a department as it is used only for employee plans.', + plan_names=', '.join(failing_plans.mapped('name'))) + ) + failing_templates = plan_tocheck.template_ids.filtered( + lambda tpl: tpl.responsible_type in {'coach', 'manager', 'employee'} + ) + if failing_templates: + raise UserError( + _('Plan activities %(template_names)s cannot use coach, manager or employee responsible as it is used only for employee plans.', + template_names=', '.join(failing_templates.mapped('activity_type_id.name'))) + ) + + @api.onchange('res_model') + def _compute_department_id(self): + for plan in self.filtered(lambda plan: plan.res_model != 'hr.employee'): + plan.department_id = False diff --git a/addons/hr/models/mail_activity_plan_template.py b/addons/hr/models/mail_activity_plan_template.py index 05b04fb542f..a16794da845 100644 --- a/addons/hr/models/mail_activity_plan_template.py +++ b/addons/hr/models/mail_activity_plan_template.py @@ -2,67 +2,48 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo.exceptions import ValidationError -class HrPlanActivityType(models.Model): - _name = 'hr.plan.activity.type' - _description = 'Plan activity type' - _rec_name = 'summary' - _check_company_auto = True +class MailActivityPLanTemplate(models.Model): + _inherit = 'mail.activity.plan.template' - company_id = fields.Many2one('res.company', default=lambda self: self.env.company) - activity_type_id = fields.Many2one( - 'mail.activity.type', 'Activity Type', - default=lambda self: self.env.ref('mail.mail_activity_data_todo'), - domain=lambda self: ['|', ('res_model', '=', False), ('res_model', '=', 'hr.employee')], - ondelete='restrict' - ) - summary = fields.Char('Summary', compute="_compute_default_summary", store=True, readonly=False) - responsible = fields.Selection([ + responsible_type = fields.Selection(selection_add=[ ('coach', 'Coach'), ('manager', 'Manager'), ('employee', 'Employee'), - ('other', 'Other')], default='employee', string='Responsible', required=True) - responsible_id = fields.Many2one( - 'res.users', - 'Other Responsible', - check_company=True, - help='Specific responsible of activity if not linked to the employee.') - plan_id = fields.Many2one('hr.plan') - note = fields.Html('Note') + ], ondelete={'coach': 'cascade', 'manager': 'cascade', 'employee': 'cascade'}) - @api.depends('activity_type_id') - def _compute_default_summary(self): - for plan_type in self: - if plan_type.activity_type_id and plan_type.activity_type_id.summary: - plan_type.summary = plan_type.activity_type_id.summary - else: - plan_type.summary = False + @api.constrains('plan_id', 'responsible_type') + def _check_responsible_hr(self): + """ Ensure that hr types are used only on employee model """ + for template in self.filtered(lambda tpl: tpl.plan_id.res_model != 'hr.employee'): + if template.responsible_type in {'coach', 'manager', 'employee'}: + raise ValidationError(_('Those responsible types are limited to Employee plans.')) - def get_responsible_id(self, employee): - warning = False - if self.responsible == 'coach': + def _determine_responsible(self, on_demand_responsible, employee): + if self.plan_id.res_model != 'hr.employee' or self.responsible_type not in {'coach', 'manager', 'employee'}: + return super()._determine_responsible(on_demand_responsible, employee) + error = False + responsible = False + if self.responsible_type == 'coach': if not employee.coach_id: - warning = _('Coach of employee %s is not set.', employee.name) + error = _('Coach of employee %s is not set.', employee.name) responsible = employee.coach_id.user_id if employee.coach_id and not responsible: - warning = _("The user of %s's coach is not set.", employee.name) - elif self.responsible == 'manager': + error = _("The user of %s's coach is not set.", employee.name) + elif self.responsible_type == 'manager': if not employee.parent_id: - warning = _('Manager of employee %s is not set.', employee.name) + error = _('Manager of employee %s is not set.', employee.name) responsible = employee.parent_id.user_id if employee.parent_id and not responsible: - warning = _("The manager of %s should be linked to a user.", employee.name) - elif self.responsible == 'employee': + error = _("The manager of %s should be linked to a user.", employee.name) + elif self.responsible_type == 'employee': responsible = employee.user_id if not responsible: - warning = _('The employee %s should be linked to a user.', employee.name) - elif self.responsible == 'other': - responsible = self.responsible_id - if not responsible: - warning = _('No specific user given on activity %s.', self.activity_type_id.name) - return { - 'responsible': responsible, - 'warning': warning, - } + error = _('The employee %s should be linked to a user.', employee.name) + if error or responsible: + return { + 'responsible': responsible, + 'error': error, + } diff --git a/addons/hr/security/hr_security.xml b/addons/hr/security/hr_security.xml index ddf2ef03351..27e5917f9cb 100644 --- a/addons/hr/security/hr_security.xml +++ b/addons/hr/security/hr_security.xml @@ -49,30 +49,6 @@ ['|',('company_id','=',False),('company_id', 'in', company_ids)] - - Plan multi-company rule - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Plan Activity Type multi-company rule - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Plan Employee Activity: multi-company rule - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Plan Employee Activity: own employee or subordinates - - ['|', ('user_id', '=', user.id), ('activity_ids.user_id', '=', user.id)] - - HR: Prevent non HR officers from accessing employee bank accounts @@ -92,5 +68,22 @@ ['|', ('country_id', '=', False), ('country_id', 'in', user.env.companies.country_id.ids)] + + + Manager can edit employee plan + + + [('res_model', '=', 'hr.employee')] + + + + + Manager can edit employee plan template + + + [('plan_id.res_model', '=', 'hr.employee')] + + + diff --git a/addons/hr/security/ir.model.access.csv b/addons/hr/security/ir.model.access.csv index 9affc1c6bea..d2ef0822d2f 100644 --- a/addons/hr/security/ir.model.access.csv +++ b/addons/hr/security/ir.model.access.csv @@ -8,14 +8,10 @@ access_hr_employee_resource_user,resource.resource.user,resource.model_resource_ access_hr_department_user,hr.department.user,model_hr_department,group_hr_user,1,1,1,1 access_hr_department_employee,hr.department.employee,model_hr_department,base.group_user,1,0,0,0 access_hr_job_user,hr.job user,model_hr_job,group_hr_user,1,1,1,1 -access_hr_plan_activity_type_employee,access_hr_plan_activity_type,model_hr_plan_activity_type,base.group_user,1,0,0,0 -access_hr_plan_employee,access_hr_plan_employee,model_hr_plan,base.group_user,1,0,0,0 -access_hr_plan_activity_type_hr_user,access_hr_plan_activity_type,model_hr_plan_activity_type,group_hr_user,1,1,1,1 -access_hr_plan_hr_user,access_hr_plan_hr_user,model_hr_plan,group_hr_user,1,1,1,1 -access_hr_plan_wizard,access.hr.plan.wizard,model_hr_plan_wizard,hr.group_hr_user,1,1,1,0 access_hr_departure_wizard,access.hr.departure.wizard,model_hr_departure_wizard,hr.group_hr_user,1,1,1,0 access_hr_work_location_user,access_hr_work_location_user,model_hr_work_location,base.group_user,1,0,0,0 access_hr_work_location_manager,access_hr_work_location_manager,model_hr_work_location,group_hr_manager,1,1,1,1 access_hr_departure_reason,access_hr_departure_reason_user,model_hr_departure_reason,group_hr_user,1,1,1,1 access_hr_contract_type_manager,hr.contract.type.manager,model_hr_contract_type,hr.group_hr_user,1,1,1,1 -access_hr_plan_employee_activity,hr.plan.employee.activity,model_hr_plan_employee_activity,base.group_user,1,0,0,0 +access_mail_activity_plan_hr_manager,mail.activity.plan.hr.manager,mail.model_mail_activity_plan,hr.group_hr_manager,1,1,1,1 +access_mail_activity_plan_template_hr_manager,mail.activity.plan.template.hr.manager,mail.model_mail_activity_plan_template,hr.group_hr_manager,1,1,1,1 diff --git a/addons/hr/tests/__init__.py b/addons/hr/tests/__init__.py index 7cefadd5133..c7df1893ad3 100644 --- a/addons/hr/tests/__init__.py +++ b/addons/hr/tests/__init__.py @@ -4,6 +4,7 @@ from . import test_hr_employee from . import test_channel from . import test_self_user_access +from . import test_mail_activity_plan from . import test_multi_company from . import test_resource from . import test_ui diff --git a/addons/hr/tests/test_mail_activity_plan.py b/addons/hr/tests/test_mail_activity_plan.py new file mode 100644 index 00000000000..0c392cfadb8 --- /dev/null +++ b/addons/hr/tests/test_mail_activity_plan.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import Command +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tests.test_mail_activity import ActivityScheduleCase +from odoo.exceptions import UserError, ValidationError +from odoo.tests import tagged, users + + +class ActivityScheduleHRCase(ActivityScheduleCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.plan_onboarding = cls.env['mail.activity.plan'].create({ + 'name': 'Test Onboarding', + 'res_model': 'hr.employee', + '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': False, + 'responsible_type': 'coach', + 'sequence': 20, + 'summary': 'Training', + }), + ] + }) + cls.plan_party = cls.env['mail.activity.plan'].create({ + 'name': 'Test Party Plan', + 'res_model': 'res.partner', + 'template_ids': [ + Command.create({ + 'activity_type_id': cls.activity_type_todo.id, + 'responsible_id': cls.user_admin.id, + 'responsible_type': 'on_demand', + 'sequence': 10, + 'summary': 'Party', + }), + ] + }) + + cls.user_manager = mail_new_test_user( + cls.env, + email='test.manager@test.mycompany.com', + groups='base.group_user,hr.group_hr_manager', + login='test_manager', + name='Test Manager', + ) + cls.user_coach = mail_new_test_user( + cls.env, + email='test.coach@test.mycompany.com', + groups='base.group_user,hr.group_hr_manager', + login='test_coach', + name='Test Coach', + ) + cls.user_employee_1 = mail_new_test_user( + cls.env, + email='test.employee1@test.mycompany.com', + groups='base.group_user,hr.group_hr_manager', + login='test_employee1', + name='Test Employee 1', + ) + cls.user_employee_2 = mail_new_test_user( + cls.env, + email='test.employee2@test.mycompany.com', + groups='base.group_user,hr.group_hr_manager', + login='test_employee2', + name='Test Employee 2', + ) + cls.user_employee_dep_b = mail_new_test_user( + cls.env, + email='test.employeedepb@test.mycompany.com', + groups='base.group_user,hr.group_hr_manager', + login='test_employee_dep_b', + name='Test Employee DepB', + ) + cls.users = cls.user_manager + cls.user_coach + cls.user_employee_1 + cls.user_employee_2 + cls.user_employee_dep_b + + cls.user_internal_basic = mail_new_test_user( + cls.env, + email='non.employee@test.mycompany.com', + groups='base.group_user', + login='non_employee', + name='Non Employee', + ) + + cls.employees = cls.env['hr.employee'].create([ + { + 'name': user.name, + 'user_id': user.id, + 'work_email': user.email, + } for user in cls.users + ]) + cls.employee_manager, cls.employee_coach, cls.employee_1, cls.employee_2, cls.employee_dep_b = cls.employees + cls.department_a = cls.env['hr.department'].create({ + 'name': 'Test Department A', + 'member_ids': [Command.link(employee.id) for employee in cls.employees - cls.employee_dep_b], + }) + cls.department_b = cls.env['hr.department'].create({ + 'name': 'Test Department B', + 'member_ids': [Command.link(cls.employee_dep_b.id)], + }) + cls.employee_1.coach_id = cls.employee_coach + cls.employee_1.parent_id = cls.employee_manager + cls.employee_2.coach_id = cls.employee_coach + cls.employee_2.parent_id = cls.employee_manager + cls.employee_coach.parent_id = cls.employee_manager + cls.employee_dep_b.coach_id = cls.employee_coach + + +@tagged('mail_activity', 'mail_activity_plan') +class TestActivitySchedule(ActivityScheduleHRCase): + + @users('admin') + def test_department(self): + """ Check that the allowed plan are filtered according to the department. """ + no_plan = self.env['mail.activity.plan'] + plan_department_a, plan_department_b = self.env['mail.activity.plan'].create([ + { + 'department_id': department.id, + 'name': f'plan {department.name}', + 'res_model': 'hr.employee', + 'template_ids': [(0, 0, {'activity_type_id': self.activity_type_todo.id})], + } for department in self.department_a + self.department_b + ]) + for employees, expected_department, authorized_plans, non_authorized_plans in ( + (self.employee_1 + self.employee_dep_b, False, self.plan_onboarding, no_plan), + (self.employee_1 + self.employee_2, self.department_a, self.plan_onboarding + plan_department_a, plan_department_b), + (self.employee_1, self.department_a, self.plan_onboarding + plan_department_a, plan_department_b), + (self.employee_dep_b, self.department_b, self.plan_onboarding + plan_department_b, plan_department_a), + ): + with self._instantiate_activity_schedule_wizard(employees) as form: + if expected_department: + self.assertEqual(form.department_id, expected_department) + else: + self.assertFalse(form.department_id) + for plan in non_authorized_plans: + self.assertNotIn(plan, form.plan_available_ids) + for plan in authorized_plans: + with self._instantiate_activity_schedule_wizard(employees) as form: + form.plan_id = plan + + def test_res_model_compatibility(self): + """ Check that we cannot change the plan model to a model different + of employee if hr plan specific features are used. """ + with self.assertRaises( + UserError, + msg="Coach, manager or employee can only be chosen as template responsible with employee plan."): + self.plan_onboarding.res_model = 'res.partner' + self.plan_onboarding.template_ids[1].responsible_type = 'on_demand' + self.plan_onboarding.res_model = 'res.partner' + self.plan_onboarding.res_model = 'hr.employee' + self.plan_onboarding.template_ids[1].responsible_type = 'manager' + with self.assertRaises( + UserError, + msg="Coach, manager or employee can only be chosen as template responsible with employee plan."): + self.plan_onboarding.res_model = 'res.partner' + self.plan_onboarding.template_ids[1].responsible_type = 'employee' + with self.assertRaises( + UserError, + msg="Coach, manager or employee can only be chosen as template responsible with employee plan."): + self.plan_onboarding.res_model = 'res.partner' + self.plan_onboarding.template_ids[1].responsible_type = 'on_demand' + self.plan_onboarding.res_model = 'res.partner' + self.plan_onboarding.res_model = 'hr.employee' + self.plan_onboarding.department_id = self.department_a + with self.assertRaises( + UserError, + msg="Department can only be set with employee plan."): + self.plan_onboarding.res_model = 'res.partner' + + def test_responsible(self): + """ Check that the responsible is correctly configured. """ + self.plan_onboarding.template_ids[0].write({ + 'responsible_type': 'manager', + 'responsible_id': False, + }) + self.plan_onboarding.write({ + 'template_ids': [(0, 0, { + 'activity_type_id': self.activity_type_todo.id, + 'summary': 'Send feedback to the manager', + 'responsible_type': 'employee', + 'sequence': 30, + })], + }) + for employees in (self.employee_1, self.employee_1 + self.employee_2): + # Happy case + form = self._instantiate_activity_schedule_wizard(employees) + form.plan_id = self.plan_onboarding + self.assertEqual(form.plan_assignation_summary, + '
    • To-Do - Manager: Plan training
    • To-Do - Coach: Training
    • ' + '
    • To-Do - Employee: Send feedback to the manager
    ') + self.assertFalse(form.has_error) + wizard = form.save() + wizard.action_schedule_plan() + for employee in employees: + activities = self.get_last_activities(employee, 3) + self.assertEqual(len(activities), 3) + self.assertEqual(activities[0].user_id, self.user_manager) + self.assertEqual(activities[1].user_id, self.user_coach) + self.assertEqual(activities[2].user_id, employee.user_id) + + # Cases with errors + self.employee_1.parent_id = False + self.employee_1.coach_id = False + form = self._instantiate_activity_schedule_wizard(employees) + form.plan_id = self.plan_onboarding + self.assertTrue(form.has_error) + n_error = form.error.count('
  • ') + self.assertEqual(n_error, 2) + self.assertIn(f'Manager of employee {self.employee_1.name} is not set.', form.error) + self.assertIn(f'Coach of employee {self.employee_1.name} is not set.', form.error) + with self.assertRaises(ValidationError): + form.save() + self.employee_1.parent_id = self.employee_manager + self.employee_1.coach_id = self.employee_coach + self.employee_coach.user_id = False + self.employee_manager.user_id = False + form = self._instantiate_activity_schedule_wizard(employees) + form.plan_id = self.plan_onboarding + self.assertTrue(form.has_error) + n_error = form.error.count('
  • ') + self.assertEqual(n_error, 2 * len(employees)) + self.assertIn(f"The user of {self.employee_1.name}'s coach is not set.", form.error) + self.assertIn(f'The manager of {self.employee_1.name} should be linked to a user.', form.error) + if len(employees) > 1: + self.assertIn(f"The user of {self.employee_2.name}'s coach is not set.", form.error) + self.assertIn(f'The manager of {self.employee_2.name} should be linked to a user.', form.error) + with self.assertRaises(ValidationError): + form.save() + self.employee_coach.user_id = self.user_coach + self.employee_manager.user_id = self.user_manager diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml index 10d5ae7bcfa..0196033953b 100644 --- a/addons/hr/views/hr_employee_views.xml +++ b/addons/hr/views/hr_employee_views.xml @@ -59,9 +59,7 @@ -
    -
    +
    @@ -252,9 +250,6 @@ hr.employee -
    -
    diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 6360e83792c..410cf177e9e 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -130,15 +130,15 @@ id="menu_config_plan" name="Activity Planning" parent="menu_human_resources_configuration" - groups="group_hr_user" + groups="group_hr_manager" sequence="100"/> diff --git a/addons/hr/views/mail_activity_plan_views.xml b/addons/hr/views/mail_activity_plan_views.xml index 1cb822ab2a9..af578ab4252 100644 --- a/addons/hr/views/mail_activity_plan_views.xml +++ b/addons/hr/views/mail_activity_plan_views.xml @@ -1,109 +1,53 @@ - - - hr.plan.view.search - hr.plan + + mail.activity.plan.template.view.form.inherit.hr + mail.activity.plan.template + - - - - - - + + + + - - hr.plan.view.tree - hr.plan + + mail.activity.plan.view.form.inherit.hr + mail.activity.plan + - - - + + + + + res_model == 'hr.employee' + + + + + + mail.activity.plan.view.tree.inherit.hr + mail.activity.plan + + + - - -
    +
    - - hr.plan.view.form - hr.plan - -
    - - - -
    -
    - - - - - - - - - - - - - - - - - - -
    - -
    -
    - - - hr.plan.activity.type.view.tree - hr.plan.activity.type - - - - - - - - - - - - hr.plan.activity.type.view.form - hr.plan.activity.type - -
    - - - - - - - - - - - - -
    -
    - - - Plans - hr.plan + + On/Offboarding Plans + mail.activity.plan tree,form - + + {'default_res_model': 'hr.employee'} + [('res_model', '=', 'hr.employee')]

    Add a new plan @@ -111,96 +55,13 @@ - - Plan Activities - hr.plan.activity.type - tree,form - -

    - Add a new plan activity -

    -
    -
    - - - hr.plan.employee.activity - - - - -
    -
    -
    -

    - -
    -
    - -
    -
    - -
    - - Next Activity: - -
    -
    -
    -
    -
    -
    -
    - - - hr.plan.employee.activity - - - - - - - - - - - - hr.plan.employee.activity - -
    - - - - - - - -
    - -
    -
    -
    -
    - - - 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 - - + + + Launch Plan + mail.activity.schedule + form + {'plan_mode': True} + new diff --git a/addons/hr/wizard/__init__.py b/addons/hr/wizard/__init__.py index 09e602381ea..b3454814216 100644 --- a/addons/hr/wizard/__init__.py +++ b/addons/hr/wizard/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import hr_plan_wizard from . import hr_departure_wizard +from . import mail_activity_schedule diff --git a/addons/hr/wizard/mail_activity_schedule.py b/addons/hr/wizard/mail_activity_schedule.py index b51e4390ab9..fde323115a4 100644 --- a/addons/hr/wizard/mail_activity_schedule.py +++ b/addons/hr/wizard/mail_activity_schedule.py @@ -1,137 +1,33 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from markupsafe import Markup - -from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError +from odoo import api, fields, models +from odoo.osv import expression -class HrPlanWizard(models.TransientModel): - _name = 'hr.plan.wizard' - _description = 'Plan Wizard' +class MailActivitySchedule(models.TransientModel): + _inherit = 'mail.activity.schedule' - def _default_plan_id(self): - # We know that all employees belong to the same company - employees = self.env['hr.employee'].browse(self.env.context.get('active_ids') if self.env.context.get('active_ids') else []) - if not employees: - return None - if len(employees.department_id) > 1: - return self.env['hr.plan'].search([ - ('company_id', '=', employees[0].company_id.id), - ('department_id', '=', False) - ], limit=1) - else: - return self.env['hr.plan'].search([ - ('company_id', '=', employees[0].company_id.id), - '|', - ('department_id', '=', employees[0].department_id.id), - ('department_id', '=', False) - ], limit=1) - - plan_id = fields.Many2one('hr.plan', default=lambda self: self._default_plan_id(), - check_company=True, - domain="['|', ('department_id', '=', department_id), ('department_id', '=', False)]") department_id = fields.Many2one('hr.department', compute='_compute_department_id') - employee_ids = fields.Many2many( - 'hr.employee', 'hr_employee_hr_plan_wizard_rel', 'employee_id', 'plan_wizard_id', string='Employee', required=True, - default=lambda self: self.env.context.get('active_ids', []), - ) - company_id = fields.Many2one('res.company', 'Company', compute='_compute_company_id', required=True) - warning = fields.Html(compute='_compute_warning') - due_date = fields.Date(string="Due Date") - @api.depends('employee_ids') + @api.depends('department_id') + def _compute_plan_available_ids(self): + todo = self.filtered(lambda s: s.res_model == 'hr.employee') + for scheduler in todo: + base_domain = self._get_plan_available_base_domain() + if not scheduler.department_id: + final_domain = expression.AND([base_domain, [('department_id', '=', False)]]) + else: + final_domain = expression.AND([base_domain, ['|', ('department_id', '=', False), ('department_id', '=', self.department_id.id)]]) + scheduler.plan_available_ids = self.env['mail.activity.plan'].search(final_domain) + super(MailActivitySchedule, self - todo)._compute_plan_available_ids() + + @api.depends('res_model_id', 'res_ids') def _compute_department_id(self): for wizard in self: - all_departments = wizard.employee_ids.department_id - wizard.department_id = False if len(all_departments) > 1 else all_departments - - @api.constrains('employee_ids') - def _check_employee_companies(self): - for wizard in self: - if len(wizard.employee_ids.mapped('company_id')) > 1: - raise ValidationError(_('The employees should belong to the same company.')) - - @api.depends('employee_ids') - def _compute_company_id(self): - for wizard in self: - wizard.company_id = wizard.employee_ids and wizard.employee_ids[0].company_id or self.env.company - - def _get_warnings(self): - self.ensure_one() - warnings = set() - for employee in self.employee_ids: - for activity_type in self.plan_id.plan_activity_type_ids: - warning = activity_type.get_responsible_id(employee)['warning'] - if warning: - warnings.add(warning) - return warnings - - @api.depends('employee_ids', 'plan_id') - def _compute_warning(self): - for wizard in self: - warnings = wizard._get_warnings() - - if warnings: - warning_display = _('The plan %s cannot be launched:
      ', wizard.plan_id.name) - for warning in warnings: - warning_display += '
    • %s
    • ' % warning - warning_display += '
    ' + if wizard.res_model == 'hr.employee': + applied_on = wizard._get_applied_on_records() + all_departments = applied_on.department_id + wizard.department_id = False if len(all_departments) > 1 else all_departments else: - warning_display = False - wizard.warning = warning_display - - def _get_activities_to_schedule(self): - return self.plan_id.plan_activity_type_ids - - def action_launch(self): - self.ensure_one() - for employee in self.employee_ids: - body = _('The plan %s has been started', self.plan_id.name) - activities = set() - for activity_type in self._get_activities_to_schedule(): - responsible = activity_type.get_responsible_id(employee)['responsible'] - record = employee - if not self.env['hr.employee'].with_user(responsible).check_access_rights('read', raise_exception=False): - record = self.env['hr.plan.employee.activity'].sudo().search([('employee_id', '=', employee.id)], limit=1) - if not record: - record = self.env['hr.plan.employee.activity'].sudo().create({ - 'employee_id': employee.id, - }) - date_deadline = self.env['mail.activity']._calculate_date_deadline(activity_type.activity_type_id) if not self.due_date else self.due_date - record.activity_schedule( - activity_type_id=activity_type.activity_type_id.id, - summary=activity_type.summary, - note=activity_type.note, - user_id=responsible.id, - date_deadline=date_deadline - ) - activity = _('%(activity)s, assigned to %(name)s, due on the %(deadline)s', activity=activity_type.summary, name=responsible.name, deadline=date_deadline) - activities.add(activity) - - if activities: - body += Markup('
      ') - for activity in activities: - body += Markup('
    • %s
    • ') % activity - body += Markup('
    ') - employee.message_post(body=body) - - if len(self.employee_ids) == 1: - return { - 'type': 'ir.actions.act_window', - 'res_model': 'hr.employee', - 'res_id': self.employee_ids.id, - 'name': self.employee_ids.display_name, - 'view_mode': 'form', - 'views': [(False, "form")], - } - - return { - 'type': 'ir.actions.act_window', - 'res_model': 'hr.employee', - 'name': _('Launch Plans'), - 'view_mode': 'tree,form', - 'target': 'current', - 'domain': [('id', 'in', self.employee_ids.ids)], - } + wizard.department_id = False diff --git a/addons/hr/wizard/mail_activity_schedule_views.xml b/addons/hr/wizard/mail_activity_schedule_views.xml index e199ebc4756..145d6cf4f54 100644 --- a/addons/hr/wizard/mail_activity_schedule_views.xml +++ b/addons/hr/wizard/mail_activity_schedule_views.xml @@ -1,37 +1,15 @@ - - plan wizard - hr.plan.wizard + + mail.activity.schedule.view.form.inherit.hr + mail.activity.schedule + -
    - - - - - - - - - - -
    -
    -
    + + +
    - - - Launch Plan - hr.plan.wizard - form - new -
    diff --git a/addons/hr_contract/tests/__init__.py b/addons/hr_contract/tests/__init__.py index 353b82263a3..7cd66c1dd6b 100644 --- a/addons/hr_contract/tests/__init__.py +++ b/addons/hr_contract/tests/__init__.py @@ -5,4 +5,5 @@ from . import test_access from . import test_auto_status from . import test_contract from . import test_calendar_sync +from . import test_mail_activity_plan from . import test_resource diff --git a/addons/hr_contract/tests/test_mail_activity_plan.py b/addons/hr_contract/tests/test_mail_activity_plan.py new file mode 100644 index 00000000000..4556a0ef361 --- /dev/null +++ b/addons/hr_contract/tests/test_mail_activity_plan.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from freezegun import freeze_time + +from odoo import fields +from odoo.addons.hr.tests.test_mail_activity_plan import ActivityScheduleHRCase +from odoo.tests import tagged, users + + +@tagged('mail_activity', 'mail_activity_plan') +class TestActivitySchedule(ActivityScheduleHRCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee_3 = cls.employee_coach + cls.employee_4 = cls.employee_manager + cls.employee_4.coach_id = cls.employee_coach + for employee, date_start in ((cls.employee_1, '2023-08-01'), + (cls.employee_2, '2023-09-01'), + (cls.employee_3, '2023-12-01'), + (cls.employee_4, '2024-01-01')): + employee.contract_ids = cls.env['hr.contract'].create({ + 'employee_id': employee.id, + 'date_end': fields.Date.from_string('2025-12-31'), + 'date_start': fields.Date.from_string(date_start), + 'name': 'Contract', + 'state': 'draft', + 'wage': 1, + }) + + @freeze_time('2023-08-31') + @users('admin') + def test_default_due_date(self): + for employees, plan_date_deadline in ( + (self.employee_1, '2023-09-30'), + (self.employee_2, '2023-09-30'), + (self.employee_3, '2023-12-01'), + (self.employee_4, '2024-01-01'), + (self.employee_1 + self.employee_2 + self.employee_3, '2023-09-30'), + (self.employee_2 + self.employee_3, '2023-09-30'), + (self.employee_1 + self.employee_3, '2023-09-30'), + (self.employee_3 + self.employee_4, '2023-12-01'), + (self.employee_4 + self.employee_3, '2023-12-01'), + ): + with self._instantiate_activity_schedule_wizard(employees) as form: + form.plan_id = self.plan_onboarding + self.assertEqual(form.plan_date_deadline, fields.Date.from_string(plan_date_deadline)) + + # not applicable on other models + customers = self.env['res.partner'].create([ + {'name': 'Customer1',}, + {'name': 'Customer2',}, + ]) + with self._instantiate_activity_schedule_wizard(customers) as form: + form.plan_id = self.plan_party + self.assertFalse(form.plan_date_deadline) diff --git a/addons/hr_contract/wizard/__init__.py b/addons/hr_contract/wizard/__init__.py index 78a353a93f0..b3454814216 100644 --- a/addons/hr_contract/wizard/__init__.py +++ b/addons/hr_contract/wizard/__init__.py @@ -2,4 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import hr_departure_wizard -from . import hr_plan_wizard +from . import mail_activity_schedule diff --git a/addons/hr_contract/wizard/mail_activity_schedule.py b/addons/hr_contract/wizard/mail_activity_schedule.py index da51faf357a..bf11ff686be 100644 --- a/addons/hr_contract/wizard/mail_activity_schedule.py +++ b/addons/hr_contract/wizard/mail_activity_schedule.py @@ -1,28 +1,23 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import fields, models from dateutil.relativedelta import relativedelta -class HrPlanWizard(models.TransientModel): - _inherit = 'hr.plan.wizard' +class MailActivitySchedule(models.TransientModel): + _inherit = 'mail.activity.schedule' - @api.model - def default_get(self, field_list=None): - result = super(HrPlanWizard, self).default_get(field_list) - selected_employees = self.env['hr.employee'].browse(self.env.context['active_ids']) - start_dates = selected_employees.filtered('first_contract_date').mapped('first_contract_date') - if start_dates: - today = fields.Date.today() - planned_due_date = start_dates[0] if len(set(start_dates)) == len(selected_employees) else min(start_dates) - - if planned_due_date < today: - result['due_date'] = today + relativedelta(days=+30) - else: - if (planned_due_date-today).days < 30: - result['due_date'] = today + relativedelta(days=+30) + def _compute_plan_date_deadline(self): + todo = self.filtered(lambda s: s.res_model == 'hr.employee') + for scheduler in todo: + selected_employees = scheduler._get_applied_on_records() + start_dates = selected_employees.filtered('first_contract_date').mapped('first_contract_date') + if start_dates: + today = fields.Date.today() + planned_due_date = min(start_dates) + if planned_due_date < today or (planned_due_date - today).days < 30: + scheduler.plan_date_deadline = today + relativedelta(days=+30) else: - result['due_date'] = planned_due_date - - return result + scheduler.plan_date_deadline = planned_due_date + super(MailActivitySchedule, self - todo)._compute_plan_date_deadline() diff --git a/addons/hr_fleet/data/hr_fleet_data.xml b/addons/hr_fleet/data/hr_fleet_data.xml index 5dd61ac9b0e..bf402238abe 100644 --- a/addons/hr_fleet/data/hr_fleet_data.xml +++ b/addons/hr_fleet/data/hr_fleet_data.xml @@ -2,9 +2,9 @@ - + Take Back Fleet - fleet_manager + fleet_manager diff --git a/addons/hr_fleet/data/hr_fleet_demo.xml b/addons/hr_fleet/data/hr_fleet_demo.xml index 85516aff431..d63afb1f7ba 100644 --- a/addons/hr_fleet/data/hr_fleet_demo.xml +++ b/addons/hr_fleet/data/hr_fleet_demo.xml @@ -21,9 +21,9 @@ - + Fleet Training - fleet_manager + fleet_manager diff --git a/addons/hr_fleet/models/__init__.py b/addons/hr_fleet/models/__init__.py index 93d6b757f88..0c6a5a31f08 100644 --- a/addons/hr_fleet/models/__init__.py +++ b/addons/hr_fleet/models/__init__.py @@ -8,4 +8,4 @@ from . import fleet_vehicle from . import fleet_vehicle_log_contract from . import fleet_vehicle_log_services from . import fleet_vehicle_odometer -from . import hr_plan_activity_type +from . import mail_activity_plan_template diff --git a/addons/hr_fleet/models/mail_activity_plan_template.py b/addons/hr_fleet/models/mail_activity_plan_template.py index 9f528c9f6b9..02f757ceb9d 100644 --- a/addons/hr_fleet/models/mail_activity_plan_template.py +++ b/addons/hr_fleet/models/mail_activity_plan_template.py @@ -1,25 +1,35 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models, _ +from odoo import api, fields, models, _ +from odoo import exceptions -class HrPlanActivityType(models.Model): - _inherit = 'hr.plan.activity.type' +class MailActivityPlanTemplate(models.Model): + _inherit = 'mail.activity.plan.template' - responsible = fields.Selection(selection_add=[('fleet_manager', "Fleet Manager"), ('employee',)], ondelete={'fleet_manager': 'set default'}) + responsible_type = fields.Selection( + selection_add=[('fleet_manager', "Fleet Manager")], + ondelete={'fleet_manager': 'set default'}) - def get_responsible_id(self, employee): - if self.responsible == 'fleet_manager': + @api.constrains('plan_id', 'responsible_type') + def _check_responsible_hr_fleet(self): + """ Ensure that hr types are used only on employee model """ + for template in self.filtered(lambda tpl: tpl.plan_id.res_model != 'hr.employee'): + if template.responsible_type == 'fleet_manager': + raise exceptions.ValidationError(_('Fleet manager responsible is limited to Employee plans.')) + + def _determine_responsible(self, on_demand_responsible, employee): + if self.responsible_type == 'fleet_manager' and self.plan_id.res_model == 'hr.employee': employee_id = self.env['hr.employee'].browse(employee._origin.id) vehicle = employee_id.car_ids[:1] - warning = False + error = False if not vehicle: - warning = _('Employee %s is not linked to a vehicle.', employee_id.name) + error = _('Employee %s is not linked to a vehicle.', employee_id.name) if vehicle and not vehicle.manager_id: - warning = _("Employee's vehicle %s is not linked to a fleet manager.", employee_id.name) + error = _("Employee's vehicle %s is not linked to a fleet manager.", employee_id.name) return { 'responsible': vehicle.manager_id, - 'warning': warning, + 'error': error, } - return super().get_responsible_id(employee) + return super()._determine_responsible(on_demand_responsible, employee) diff --git a/addons/hr_fleet/tests/__init__.py b/addons/hr_fleet/tests/__init__.py new file mode 100644 index 00000000000..29cb3694159 --- /dev/null +++ b/addons/hr_fleet/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_mail_activity_plan diff --git a/addons/hr_fleet/tests/test_mail_activity_plan.py b/addons/hr_fleet/tests/test_mail_activity_plan.py new file mode 100644 index 00000000000..ddf4ae92864 --- /dev/null +++ b/addons/hr_fleet/tests/test_mail_activity_plan.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import Command +from odoo.addons.hr.tests.test_mail_activity_plan import ActivityScheduleHRCase +from odoo.exceptions import ValidationError +from odoo.tests import tagged, users + + +@tagged('mail_activity', 'mail_activity_plan') +class TestActivitySchedule(ActivityScheduleHRCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.plan_fleet = cls.env['mail.activity.plan'].create({ + 'name': 'Car return plan', + 'res_model': 'hr.employee', + 'template_ids': [Command.create({ + 'activity_type_id': cls.activity_type_todo.id, + 'responsible_type': 'fleet_manager', + 'summary': 'Car return', + })] + }) + cls.brand = cls.env["fleet.vehicle.model.brand"].create({"name": "Audi"}) + for employee in (cls.employee_1, cls.employee_2): + car = cls.env["fleet.vehicle"].create({ + "driver_id": employee.user_id.partner_id.id, + "model_id": cls.env["fleet.vehicle.model"].create({ + "brand_id": cls.brand.id, + "name": "A3", + }).id, + "manager_id": cls.user_manager.id, + "plan_to_change_car": False, + }) + employee.car_ids = car + + @users('admin') + def test_responsible(self): + """ Check that the responsible is correctly configured. """ + employees = (self.employee_1 + self.employee_2).with_env(self.env) + + # Happy case + form = self._instantiate_activity_schedule_wizard(employees) + form.plan_id = self.plan_fleet + self.assertEqual(form.plan_assignation_summary, + '
    • To-Do - Fleet Manager: Car return
    ') + self.assertFalse(form.has_error) + wizard = form.save() + wizard.action_schedule_plan() + for employee in employees: + activities = self.get_last_activities(employee, 1) + self.assertEqual(len(activities), 1) + self.assertEqual(activities[0].user_id, self.user_manager) + + # Cases with errors + self.employee_1.car_ids[0].manager_id = False + form = self._instantiate_activity_schedule_wizard(employees) + form.plan_id = self.plan_fleet + self.assertTrue(form.has_error) + n_error = form.error.count('
  • ') + self.assertEqual(n_error, 1) + self.assertIn(f"Employee's vehicle {self.employee_1.name} is not linked to a fleet manager.", form.error) + with self.assertRaises(ValidationError): + form.save() + + self.employee_1.car_ids = self.env["fleet.vehicle"] + form = self._instantiate_activity_schedule_wizard(employees) + form.plan_id = self.plan_fleet + self.assertTrue(form.has_error) + n_error = form.error.count('
  • ') + self.assertEqual(n_error, 1) + self.assertIn(f"Employee {self.employee_1.name} is not linked to a vehicle.", form.error) + with self.assertRaises(ValidationError): + form.save()