Skip to content

Commit 20b318b

Browse files
committed
[IMP] project: dispatch tasks based on project roles
In this commit, we add a new feature allowing project tasks to be dispatched automatically based on: - The roles we have defined in the tasks of a project template - And a role-to-users mapping showing up when creating a new project from a project template This feature enhances task management by ensuring that tasks are assigned to the appropriate team members according to their roles, improving efficiency and clarity in project execution. More precisely, we add a new model: 'Project Roles' that we can link to tasks templates (as long as they belong to a project template). We can link multiples roles to a single task template, and a role can be linked to different tasks too. A menu item for 'Project Roles' has been added. When creating a new project from a project template, the user can select in the wizard which users are going to be assigned to a task if the role matches. This mapping is only visible if at least one role has been defined in any task of the project template, and the available roles in that mapping are only the roles defined in those tasks. In case several roles match for a given task, we will tie break with the first matching entry of the mapping (which is sortable by sequence). If a task already contains some assignees, they will not be overriden but we will add the matching users to the existing assignees. task-4700746
1 parent a036c66 commit 20b318b

13 files changed

+287
-8
lines changed

addons/hr_timesheet/models/project_project.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def action_view_tasks(self):
284284
action['context']['allow_timesheets'] = self.allow_timesheets
285285
return action
286286

287-
def action_create_from_template(self, values=None):
288-
project = super().action_create_from_template(values)
287+
def action_create_from_template(self, values=None, role_to_users_mapping=None):
288+
project = super().action_create_from_template(values, role_to_users_mapping)
289289
project._create_analytic_account()
290290
return project

addons/project/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
'views/project_task_type_views.xml',
3636
'views/project_project_views.xml',
3737
'views/project_task_views.xml',
38+
'views/project_role_views.xml',
3839
'views/project_tags_views.xml',
3940
'views/project_milestone_views.xml',
4041
'views/res_partner_views.xml',

addons/project/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from . import project_task_stage_personal
1010
from . import project_milestone
1111
from . import project_project
12+
from . import project_role
1213
from . import project_task
1314
from . import project_task_type
1415
from . import project_tags

addons/project/models/project_project.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1266,6 +1266,8 @@ def _toggle_template_mode(self, is_template):
12661266
self.ensure_one()
12671267
self.is_template = is_template
12681268
self.task_ids.write({"is_template": is_template})
1269+
if not is_template:
1270+
self.task_ids.role_ids = False
12691271

12701272
@api.model
12711273
def _get_template_default_context_whitelist(self):
@@ -1283,7 +1285,7 @@ def _get_template_field_blacklist(self):
12831285
"partner_id",
12841286
]
12851287

1286-
def action_create_from_template(self, values=None):
1288+
def action_create_from_template(self, values=None, role_to_users_mapping=None):
12871289
self.ensure_one()
12881290
values = values or {}
12891291
default = {
@@ -1296,4 +1298,13 @@ def action_create_from_template(self, values=None):
12961298
}
12971299
project = self.with_context(copy_from_template=True).copy(default=default)
12981300
project.message_post(body=self.env._("Project created from template %(name)s.", name=self.name))
1301+
1302+
# Tasks dispatching using project roles
1303+
project.task_ids.role_ids = False
1304+
if role_to_users_mapping:
1305+
for template_task, new_task in zip(self.task_ids, project.task_ids):
1306+
for entry in role_to_users_mapping:
1307+
if entry.role_id in template_task.role_ids:
1308+
new_task.user_ids |= entry.user_ids
1309+
break
12991310
return project

addons/project/models/project_role.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from random import randint
2+
3+
from odoo import fields, models
4+
5+
6+
class ProjectRole(models.Model):
7+
_name = 'project.role'
8+
_description = 'Project Role'
9+
10+
def _get_default_color(self):
11+
return randint(1, 11)
12+
13+
active = fields.Boolean(default=True)
14+
name = fields.Char(required=True, translate=True)
15+
color = fields.Integer(default=_get_default_color)
16+
sequence = fields.Integer(export_string_translation=False)
17+
18+
def copy_data(self, default=None):
19+
vals_list = super().copy_data(default=default)
20+
return [dict(vals, name=self.env._('%s (copy)', role.name)) for role, vals in zip(self, vals_list)]

addons/project/models/project_task.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def _read_group_personal_stage_type_ids(self, stages, domain):
186186
allocated_hours = fields.Float("Allocated Time", tracking=True)
187187
subtask_allocated_hours = fields.Float("Sub-tasks Allocated Time", compute='_compute_subtask_allocated_hours', export_string_translation=False,
188188
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.")
189+
role_ids = fields.Many2many('project.role', string='Project Roles')
189190
# Tracking of this field is done in the write function
190191
user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id',
191192
string='Assignees', context={'active_test': False}, tracking=True, default=_default_user_ids, domain="[('share', '=', False), ('active', '=', True)]", falsy_value_label=_lt("👤 Unassigned"))
@@ -306,6 +307,7 @@ def _read_group_personal_stage_type_ids(self, stages, domain):
306307
)
307308
link_preview_name = fields.Char(compute='_compute_link_preview_name', export_string_translation=False)
308309
is_template = fields.Boolean(copy=False, export_string_translation=False)
310+
has_project_template = fields.Boolean(related='project_id.is_template', export_string_translation=False)
309311
has_template_ancestor = fields.Boolean(compute='_compute_has_template_ancestor', search='_search_has_template_ancestor',
310312
recursive=True, export_string_translation=False)
311313

@@ -1937,6 +1939,7 @@ def action_convert_to_template(self):
19371939
def action_undo_convert_to_template(self):
19381940
self.ensure_one()
19391941
self.is_template = False
1942+
self.role_ids = False
19401943
self.message_post(body=_("Template converted back to regular task"))
19411944
return {
19421945
'type': 'ir.actions.client',

addons/project/security/ir.model.access.csv

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,7 @@ access_mail_activity_plan_project_manager,mail.activity.plan.project.manager,mai
4848
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
4949
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
5050
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
51+
access_project_template_role_to_users_map_user,project.template.role.to.users.map.user,project.model_project_template_role_to_users_map,project.group_project_user,1,1,0,0
52+
access_project_template_role_to_users_map_manager,project.template.role.to.users.map.manager,project.model_project_template_role_to_users_map,project.group_project_manager,1,1,1,1
53+
access_project_role_user,project.role.user,model_project_role,project.group_project_user,1,0,0,0
54+
access_project_role_manager,project.role.manager,model_project_role,project.group_project_manager,1,1,1,1

addons/project/tests/test_project_template.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from odoo.exceptions import UserError
2+
from odoo import Command
23
from odoo.addons.project.tests.test_project_base import TestProjectCommon
34

45

@@ -49,3 +50,111 @@ def test_revert_task_template(self):
4950
self.task_inside_template.is_template = True
5051
with self.assertRaises(UserError, msg="A UserError should be raised when attempting to revert a template task to a regular one."):
5152
self.task_inside_template.action_convert_to_template()
53+
54+
def test_tasks_dispatching_from_template(self):
55+
"""
56+
The tasks of a project template should be dispatched to the new project according to the role-to-users mapping defined
57+
on the project template wizard.
58+
"""
59+
role1, role2, role3, role4, role5 = self.env['project.role'].create([
60+
{'name': 'Developer'},
61+
{'name': 'Designer'},
62+
{'name': 'Project Manager'},
63+
{'name': 'Tester'},
64+
{'name': 'Product Owner'},
65+
])
66+
project_template = self.env['project.project'].create({
67+
'name': 'Project template',
68+
'is_template': True,
69+
'task_ids': [
70+
Command.create({
71+
'name': 'Task 1',
72+
'role_ids': [role1.id, role3.id],
73+
}),
74+
Command.create({
75+
'name': 'Task 2',
76+
'role_ids': [role5.id, role4.id],
77+
}),
78+
Command.create({
79+
'name': 'Task 3',
80+
'role_ids': [role2.id, role5.id],
81+
}),
82+
Command.create({
83+
'name': 'Task 4',
84+
'role_ids': [role3.id],
85+
}),
86+
Command.create({
87+
'name': 'Task 5',
88+
'role_ids': [role5.id],
89+
}),
90+
Command.create({
91+
'name': 'Task 6',
92+
}),
93+
],
94+
})
95+
user1, user2 = self.env['res.users'].create([
96+
{
97+
'name': 'Test User 1',
98+
'login': 'test1',
99+
'password': 'testuser1',
100+
'email': '[email protected]',
101+
},
102+
{
103+
'name': 'Test User 2',
104+
'login': 'test2',
105+
'password': 'testuser2',
106+
'email': '[email protected]',
107+
}
108+
])
109+
wizard = self.env['project.template.create.wizard'].create({
110+
'template_id': project_template.id,
111+
'name': 'New Project from Template',
112+
'role_to_users_ids': [
113+
Command.create({
114+
'role_id': role1.id,
115+
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
116+
}),
117+
Command.create({
118+
'role_id': role2.id,
119+
'user_ids': [user1.id],
120+
}),
121+
Command.create({
122+
'role_id': role3.id,
123+
'user_ids': [user2.id],
124+
}),
125+
Command.create({
126+
'role_id': role4.id,
127+
'user_ids': [self.user_projectuser.id],
128+
}),
129+
],
130+
})
131+
new_project = wizard._create_project_from_template()
132+
133+
self.assertEqual(
134+
new_project.task_ids.filtered(lambda t: t.name == 'Task 1').user_ids,
135+
self.user_projectuser + self.user_projectmanager,
136+
'Task 1 should be assigned to the users mapped to `role1`.',
137+
)
138+
self.assertEqual(
139+
new_project.task_ids.filtered(lambda t: t.name == 'Task 2').user_ids,
140+
self.user_projectuser,
141+
'Task 2 should be assigned to the users mapped to `role4`. As `role5` is not in the mapping.',
142+
)
143+
self.assertEqual(
144+
new_project.task_ids.filtered(lambda t: t.name == 'Task 3').user_ids,
145+
user1,
146+
'Task 3 should be assigned to the users mapped to `role2`.',
147+
)
148+
self.assertEqual(
149+
new_project.task_ids.filtered(lambda t: t.name == 'Task 4').user_ids,
150+
user2,
151+
'Task 4 should be assigned to the users mapped to `role3`.'
152+
)
153+
self.assertFalse(
154+
new_project.task_ids.filtered(lambda t: t.name == 'Task 5').user_ids,
155+
'Task 5 should not be assigned to any user as `role5` is not in the mapping.',
156+
)
157+
self.assertFalse(
158+
new_project.task_ids.filtered(lambda t: t.name == 'Task 6').user_ids,
159+
'Task 6 should not be assigned to any user as it has no role.',
160+
)

addons/project/views/project_menus.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@
113113
id="project_menu_config_task_templates"
114114
action="project_task_templates_action"
115115
/>
116+
<menuitem
117+
name="Project Roles"
118+
id="project_menu_config_project_roles"
119+
action="project_roles_action"
120+
/>
116121
<menuitem
117122
name="Activity Types"
118123
id="project_menu_config_activity_type"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<odoo>
3+
<record id="project_role_view_list" model="ir.ui.view">
4+
<field name="name">project.role.list</field>
5+
<field name="model">project.role</field>
6+
<field name="arch" type="xml">
7+
<list editable="bottom" multi_edit="1">
8+
<field name="sequence" widget="handle"/>
9+
<field name="name" placeholder="e.g. Developer"/>
10+
<field name="color" widget="color_picker" optional="show"/>
11+
</list>
12+
</field>
13+
</record>
14+
15+
<record id="project_role_view_form" model="ir.ui.view">
16+
<field name="name">project.role.form</field>
17+
<field name="model">project.role</field>
18+
<field name="arch" type="xml">
19+
<form>
20+
<sheet>
21+
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
22+
<group>
23+
<field name="active" invisible="1"/>
24+
<field name="name"/>
25+
<field name="color" widget="color_picker"/>
26+
</group>
27+
</sheet>
28+
</form>
29+
</field>
30+
</record>
31+
32+
<record id="project_role_view_kanban" model="ir.ui.view">
33+
<field name="name">project.role.kanban</field>
34+
<field name="model">project.role</field>
35+
<field name="arch" type="xml">
36+
<kanban highlight_color="color">
37+
<templates>
38+
<t t-name="menu">
39+
<a t-if="widget.editable" role="menuitem" type="open" class="dropdown-item">Edit</a>
40+
<a t-if="widget.deletable" role="menuitem" type="delete" class="dropdown-item">Delete</a>
41+
<field name="color" widget="kanban_color_picker"/>
42+
</t>
43+
<t t-name="card">
44+
<field name="name" class="fw-bold fs-4 ms-1"/>
45+
</t>
46+
</templates>
47+
</kanban>
48+
</field>
49+
</record>
50+
51+
<record id="project_role_view_search" model="ir.ui.view">
52+
<field name="name">project.role.search</field>
53+
<field name="model">project.role</field>
54+
<field name="arch" type="xml">
55+
<search>
56+
<field name="name"/>
57+
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
58+
</search>
59+
</field>
60+
</record>
61+
62+
<record id="project_roles_action" model="ir.actions.act_window">
63+
<field name="name">Project Roles</field>
64+
<field name="res_model">project.role</field>
65+
<field name="view_mode">list,kanban,form</field>
66+
<field name="search_view_id" ref="project_role_view_search"/>
67+
<field name="help" type="html">
68+
<p class="o_view_nocontent_smiling_face">
69+
No project role found. Let's create one!
70+
</p>
71+
</field>
72+
</record>
73+
74+
<record id="project_roles_action_list" model="ir.actions.act_window.view">
75+
<field name="act_window_id" ref="project_roles_action"/>
76+
<field name="view_mode">list</field>
77+
<field name="view_id" ref="project.project_role_view_list"/>
78+
</record>
79+
80+
<record id="project_roles_action_kanban" model="ir.actions.act_window.view">
81+
<field name="act_window_id" ref="project_roles_action"/>
82+
<field name="view_mode">kanban</field>
83+
<field name="view_id" ref="project.project_role_view_kanban"/>
84+
</record>
85+
</odoo>

addons/project/views/project_task_views.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@
419419
class="o_task_user_field"
420420
options="{'no_open': True, 'no_quick_create': True}"
421421
widget="many2many_avatar_user"/>
422+
<field name="role_ids" invisible="not (is_template and has_project_template)" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}" placeholder="Assign at project creation"/>
422423
<field name="priority" widget="priority_switch"/>
423424
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}" context="{'project_id': project_id}"/>
424425
</group>

addons/project/wizard/project_template_create_wizard.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,33 @@ class ProjectTemplateCreateWizard(models.TransientModel):
1212
alias_domain_id = fields.Many2one("mail.alias.domain", string="Alias Domain")
1313
partner_id = fields.Many2one("res.partner")
1414
template_id = fields.Many2one("project.project", default=lambda self: self._context.get('template_id'))
15+
task_role_ids = fields.Many2many('project.role', compute='_compute_task_role_ids', export_string_translation=False)
16+
role_to_users_ids = fields.One2many('project.template.role.to.users.map', 'wizard_id')
1517

16-
def create_project_from_template(self):
17-
# Dictionary with all fields and their values
18+
@api.depends('template_id.task_ids.role_ids')
19+
def _compute_task_role_ids(self):
20+
for map in self:
21+
map.task_role_ids = map.template_id.task_ids.role_ids
22+
23+
def _get_template_whitelist_fields(self):
24+
"""
25+
Whitelist of fields of this wizard that will be used when creating a project from a template.
26+
"""
27+
return ["name", "date_start", "date", "alias_name", "alias_domain_id", "partner_id"]
28+
29+
def _create_project_from_template(self):
30+
# Dictionary with all whitelist fields and their values
1831
field_values = self._convert_to_write(
1932
{
2033
fname: self[fname]
21-
for fname in self._fields.keys() - ["id", "template_id"]
34+
for fname in self._fields.keys() & self._get_template_whitelist_fields()
2235
}
2336
)
24-
project = self.template_id.action_create_from_template(field_values)
37+
return self.template_id.action_create_from_template(values=field_values, role_to_users_mapping=self.role_to_users_ids)
38+
39+
def create_project_from_template(self):
2540
# Opening project task views after creation of project from template
26-
return project.action_view_tasks()
41+
return self._create_project_from_template().action_view_tasks()
2742

2843
@api.model
2944
def action_open_template_view(self):
@@ -40,3 +55,20 @@ def action_open_template_view(self):
4055
'target': 'new',
4156
'context': self.env.context,
4257
}
58+
59+
60+
class ProjectTemplateRoleToUsersMap(models.TransientModel):
61+
_name = 'project.template.role.to.users.map'
62+
_description = 'Project role to users mapping'
63+
_order = 'sequence, id'
64+
65+
sequence = fields.Integer(default=10)
66+
wizard_id = fields.Many2one('project.template.create.wizard', export_string_translation=False)
67+
task_role_ids = fields.Many2many('project.role', related='wizard_id.task_role_ids', export_string_translation=False)
68+
role_id = fields.Many2one('project.role', string='Project Role', domain='[("id", "in", task_role_ids)]', required=True)
69+
user_ids = fields.Many2many('res.users', string='Assignees', required=True)
70+
71+
_role_uniq = models.Constraint(
72+
'UNIQUE(wizard_id, role_id)',
73+
'A role cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.',
74+
)

0 commit comments

Comments
 (0)