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()