diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py
index 7120c0ffd5587..5d5f172840b93 100644
--- a/addons/project/models/__init__.py
+++ b/addons/project/models/__init__.py
@@ -9,6 +9,8 @@
from . import project_task_stage_personal
from . import project_milestone
from . import project_project
+from . import project_role_user_map
+from . import project_role
from . import project_task
from . import project_task_type
from . import project_tags
diff --git a/addons/project/models/project_project.py b/addons/project/models/project_project.py
index 2d6cff53eb6f1..6aedcd65ea729 100644
--- a/addons/project/models/project_project.py
+++ b/addons/project/models/project_project.py
@@ -111,7 +111,7 @@ def _set_favorite_user_ids(self, is_favorite):
task_count = fields.Integer(compute='_compute_task_count', string="Task Count", export_string_translation=False)
open_task_count = fields.Integer(compute='_compute_open_task_count', string="Open Task Count", export_string_translation=False)
task_ids = fields.One2many('project.task', 'project_id', string='Tasks', export_string_translation=False,
- domain=lambda self: [('is_closed', '=', False), ('is_template', '=', False)])
+ domain="[('is_closed', '=', False), ('is_template', '=', is_template)])")
color = fields.Integer(string='Color Index', export_string_translation=False)
user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True, falsy_value_label=_lt("👤 Unassigned"))
alias_id = fields.Many2one(help="Internal email associated with this project. Incoming emails are automatically synchronized "
@@ -146,6 +146,8 @@ def _set_favorite_user_ids(self, is_favorite):
task_properties_definition = fields.PropertiesDefinition('Task Properties')
closed_task_count = fields.Integer(compute="_compute_closed_task_count", export_string_translation=False)
task_completion_percentage = fields.Float(compute="_compute_task_completion_percentage", export_string_translation=False)
+ role_user_ids = fields.One2many('project.role.user.map', 'project_id', copy=True)
+ show_project_roles = fields.Boolean(compute='_compute_show_project_roles', export_string_translation=False)
# Project Sharing fields
collaborator_ids = fields.One2many('project.collaborator', 'project_id', string='Collaborators', copy=False, export_string_translation=False)
@@ -313,6 +315,10 @@ def _compute_last_update_color(self):
for project in self:
project.last_update_color = STATUS_COLOR[project.last_update_status]
+ def _compute_show_project_roles(self):
+ for project in self:
+ project.show_project_roles = bool(project.task_ids.role_ids)
+
@api.depends('milestone_ids')
def _compute_milestone_count(self):
read_group = self.env['project.milestone']._read_group([('project_id', 'in', self.ids)], ['project_id'], ['__count'])
@@ -1255,6 +1261,8 @@ def _toggle_template_mode(self, is_template):
self.ensure_one()
self.is_template = is_template
self.task_ids.write({"is_template": is_template})
+ if not is_template:
+ self.role_user_ids.unlink()
@api.model
def _get_template_default_context_whitelist(self):
@@ -1283,4 +1291,11 @@ def action_create_from_template(self, values=None):
field: False
for field in self._get_template_field_blacklist()
}
- return self.with_context(copy_from_template=True).copy(default=default)
+ new_project = self.with_context(copy_from_template=True).copy(default=default)
+ # Tasks dispatching with project roles
+ for original_task, new_task in zip(self.task_ids, new_project.task_ids):
+ for role in original_task.role_ids:
+ if users := self.role_user_ids.filtered(lambda m: m.role_id == role).user_ids:
+ new_task.user_ids = users
+ break
+ return new_project
diff --git a/addons/project/models/project_role.py b/addons/project/models/project_role.py
new file mode 100644
index 0000000000000..5c65208b2e51f
--- /dev/null
+++ b/addons/project/models/project_role.py
@@ -0,0 +1,20 @@
+from random import randint
+
+from odoo import fields, models
+
+
+class ProjectRole(models.Model):
+ _name = 'project.role'
+ _description = 'Project Role'
+
+ def _get_default_color(self):
+ return randint(1, 11)
+
+ active = fields.Boolean(default=True)
+ name = fields.Char(required=True, translate=True)
+ color = fields.Integer(default=_get_default_color)
+ sequence = fields.Integer(export_string_translation=False)
+
+ def copy_data(self, default=None):
+ vals_list = super().copy_data(default=default)
+ return [dict(vals, name=self.env._('%s (copy)', role.name)) for role, vals in zip(self, vals_list)]
diff --git a/addons/project/models/project_role_user_map.py b/addons/project/models/project_role_user_map.py
new file mode 100644
index 0000000000000..217fee9845a58
--- /dev/null
+++ b/addons/project/models/project_role_user_map.py
@@ -0,0 +1,21 @@
+from odoo import api, fields, models
+
+
+class ProjectRoleUserMap(models.Model):
+ _name = 'project.role.user.map'
+ _description = 'Project role by users mapping'
+
+ project_id = fields.Many2one('project.project', domain=[('is_template', '=', True)], required=True)
+ task_role_ids = fields.Many2many('project.role', compute='_compute_task_role_ids')
+ role_id = fields.Many2one('project.role', domain="[('id', 'in', task_role_ids)]", required=True)
+ user_ids = fields.Many2many('res.users', string='Assignees', required=True)
+
+ _role_uniq = models.Constraint(
+ 'UNIQUE(project_id,role_id)',
+ 'A role cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.',
+ )
+
+ @api.depends('project_id.task_ids.role_ids')
+ def _compute_task_role_ids(self):
+ for map in self:
+ map.task_role_ids = map.project_id.task_ids.role_ids
diff --git a/addons/project/models/project_task.py b/addons/project/models/project_task.py
index 64915e8efc5ed..675e31844a7ae 100644
--- a/addons/project/models/project_task.py
+++ b/addons/project/models/project_task.py
@@ -179,13 +179,14 @@ def _read_group_personal_stage_type_ids(self, stages, domain):
help="Date on which the state of your task has last been modified.\n"
"Based on this information you can identify tasks that are stalling and get statistics on the time it usually takes to move tasks from one stage/state to another.")
- project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_template', '=', False)]",
+ project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_template', '=', is_template)]",
compute="_compute_project_id", store=True, precompute=True, recursive=True, readonly=False, index=True, tracking=True, change_default=True, falsy_value_label=_lt("🔒 Private"))
display_in_project = fields.Boolean(compute='_compute_display_in_project', store=True, export_string_translation=False)
task_properties = fields.Properties('Properties', definition='project_id.task_properties_definition', copy=True)
allocated_hours = fields.Float("Allocated Time", tracking=True)
subtask_allocated_hours = fields.Float("Sub-tasks Allocated Time", compute='_compute_subtask_allocated_hours', export_string_translation=False,
help="Sum of the hours allocated for all the sub-tasks (and their own sub-tasks) linked to this task. Usually less than or equal to the allocated hours of this task.")
+ role_ids = fields.Many2many('project.role', string='Roles')
# Tracking of this field is done in the write function
user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id',
string='Assignees', context={'active_test': False}, tracking=True, default=_default_user_ids, domain="[('share', '=', False), ('active', '=', True)]", falsy_value_label=_lt("👤 Unassigned"))
@@ -1934,6 +1935,7 @@ def action_convert_to_template(self):
def action_undo_convert_to_template(self):
self.ensure_one()
self.is_template = False
+ self.role_ids = False
self.message_post(body=_("Template converted back to regular task"))
return {
'type': 'ir.actions.client',
diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv
index d19f943ae1414..38bf9b3be5039 100644
--- a/addons/project/security/ir.model.access.csv
+++ b/addons/project/security/ir.model.access.csv
@@ -48,3 +48,7 @@ access_mail_activity_plan_project_manager,mail.activity.plan.project.manager,mai
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
access_project_template_create_wizard_user,project.template.create.wizard.user,project.model_project_template_create_wizard,project.group_project_user,1,1,0,0
access_project_template_create_wizard_manager,project.template.create.wizard.manager,project.model_project_template_create_wizard,project.group_project_manager,1,1,1,1
+access_project_role_user,project.role.user,model_project_role,project.group_project_user,1,0,0,0
+access_project_role_manager,project.role.manager,model_project_role,project.group_project_manager,1,1,1,1
+access_project_role_user_map_user,project.role.user.map.user,model_project_role_user_map,project.group_project_user,1,0,0,0
+access_project_role_user_map_manager,project.role.user.map.manager,model_project_role_user_map,project.group_project_manager,1,1,1,1
diff --git a/addons/project/tests/test_project_template.py b/addons/project/tests/test_project_template.py
index 84a8f23d02e75..d76e7226df297 100644
--- a/addons/project/tests/test_project_template.py
+++ b/addons/project/tests/test_project_template.py
@@ -38,3 +38,117 @@ def test_revert_template(self):
"""
self.project_template.action_undo_convert_to_template()
self.assertFalse(self.project_template.is_template, "The reverted template should become a normal template.")
+
+ def test_tasks_dispatching_from_template(self):
+ """
+ The tasks of a project template should be dispatched to the new project according to the role-users mapping defined
+ on the project template.
+ """
+ role1, role2, role3, role4, role5 = self.env['project.role'].create([
+ {'name': 'Developer'},
+ {'name': 'Designer'},
+ {'name': 'Project Manager'},
+ {'name': 'Tester'},
+ {'name': 'Product Owner'},
+ ])
+ project_template = self.env['project.project'].create({
+ 'name': 'Project template with user-roles mapping',
+ 'is_template': True,
+ })
+ self.env['project.task'].create([
+ {
+ 'name': 'Task 1',
+ 'project_id': project_template.id,
+ 'role_ids': [role1.id, role3.id],
+ },
+ {
+ 'name': 'Task 2',
+ 'project_id': project_template.id,
+ 'role_ids': [role5.id, role4.id],
+ },
+ {
+ 'name': 'Task 3',
+ 'project_id': project_template.id,
+ 'role_ids': [role2.id, role5.id],
+ },
+ {
+ 'name': 'Task 4',
+ 'project_id': project_template.id,
+ 'role_ids': [role3.id],
+ },
+ {
+ 'name': 'Task 5',
+ 'project_id': project_template.id,
+ 'role_ids': [role5.id],
+ },
+ {
+ 'name': 'Task 6',
+ 'project_id': project_template.id,
+ }
+ ])
+ user1, user2 = self.env['res.users'].create([
+ {
+ 'name': 'Test User 1',
+ 'login': 'test1',
+ 'password': 'test1',
+ 'email': 'test1.test@example.com',
+ },
+ {
+ 'name': 'Test User 2',
+ 'login': 'test2',
+ 'password': 'test2',
+ 'email': 'test2.test@example.com',
+ }
+ ])
+ self.env['project.role.user.map'].create([
+ {
+ 'project_id': project_template.id,
+ 'role_id': role1.id,
+ 'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
+ },
+ {
+ 'project_id': project_template.id,
+ 'role_id': role2.id,
+ 'user_ids': [user1.id],
+ },
+ {
+ 'project_id': project_template.id,
+ 'role_id': role3.id,
+ 'user_ids': [user2.id],
+ },
+ {
+ 'project_id': project_template.id,
+ 'role_id': role4.id,
+ 'user_ids': [self.user_projectuser.id],
+ },
+ ])
+
+ new_project = project_template.action_create_from_template()
+ self.assertEqual(
+ new_project.task_ids.filtered(lambda t: t.name == 'Task 1').user_ids,
+ self.user_projectuser + self.user_projectmanager,
+ 'Task 1 should be assigned to the users mapped to `role1`.',
+ )
+ self.assertEqual(
+ new_project.task_ids.filtered(lambda t: t.name == 'Task 2').user_ids,
+ self.user_projectuser,
+ 'Task 2 should be assigned to the users mapped to `role4`. As `role5` is not in the mapping.',
+ )
+ self.assertEqual(
+ new_project.task_ids.filtered(lambda t: t.name == 'Task 3').user_ids,
+ user1,
+ 'Task 3 should be assigned to the users mapped to `role2`.',
+ )
+ self.assertEqual(
+ new_project.task_ids.filtered(lambda t: t.name == 'Task 4').user_ids,
+ user2,
+ 'Task 4 should be assigned to the users mapped to `role3`.'
+ )
+ self.assertFalse(
+ new_project.task_ids.filtered(lambda t: t.name == 'Task 5').user_ids,
+ 'Task 5 should not be assigned to any user as `role5` is not in the mapping.',
+ )
+ self.assertFalse(
+ new_project.task_ids.filtered(lambda t: t.name == 'Task 6').user_ids,
+ 'Task 6 should not be assigned to any user as it has no role.',
+ )
diff --git a/addons/project/views/project_project_views.xml b/addons/project/views/project_project_views.xml
index 9469e3c5f44a2..3ef3e1f30e0f1 100644
--- a/addons/project/views/project_project_views.xml
+++ b/addons/project/views/project_project_views.xml
@@ -157,6 +157,14 @@
+
+
+
+
+
+
+
+
diff --git a/addons/project/views/project_task_views.xml b/addons/project/views/project_task_views.xml
index 222db324eb2c2..fa8285fcb82f8 100644
--- a/addons/project/views/project_task_views.xml
+++ b/addons/project/views/project_task_views.xml
@@ -405,7 +405,7 @@
+