Skip to content

Commit 46889cf

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 the task if some roles match. 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. If multiple roles match, all the users matching those roles will be assigned to the task. If a task already contains some assignees, they will not be overridden but the matching users will be added to the existing assignees. task-4700746
1 parent 3b57a27 commit 46889cf

14 files changed

+313
-30
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: 11 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,12 @@ 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 and (mapping := role_to_users_mapping.filtered(lambda entry: entry.user_ids)):
1305+
for template_task, new_task in zip(self.task_ids, project.task_ids):
1306+
for entry in mapping:
1307+
if entry.role_id in template_task.role_ids:
1308+
new_task.user_ids |= entry.user_ids
12991309
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: 2 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"))
@@ -1938,6 +1939,7 @@ def action_convert_to_template(self):
19381939
def action_undo_convert_to_template(self):
19391940
self.ensure_one()
19401941
self.is_template = False
1942+
self.role_ids = False
19411943
self.message_post(body=_("Template converted back to regular task"))
19421944
return {
19431945
'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: 119 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,121 @@ 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+
Command.create({
94+
'name': 'Task 7',
95+
'role_ids': [role2.id, role3.id],
96+
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
97+
}),
98+
],
99+
})
100+
user1, user2 = self.env['res.users'].create([
101+
{
102+
'name': 'Test User 1',
103+
'login': 'test1',
104+
'password': 'testuser1',
105+
'email': '[email protected]',
106+
},
107+
{
108+
'name': 'Test User 2',
109+
'login': 'test2',
110+
'password': 'testuser2',
111+
'email': '[email protected]',
112+
}
113+
])
114+
wizard = self.env['project.template.create.wizard'].create({
115+
'template_id': project_template.id,
116+
'name': 'New Project from Template',
117+
'role_to_users_ids': [
118+
Command.create({
119+
'role_id': role1.id,
120+
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
121+
}),
122+
Command.create({
123+
'role_id': role2.id,
124+
'user_ids': [user1.id],
125+
}),
126+
Command.create({
127+
'role_id': role3.id,
128+
'user_ids': [user2.id],
129+
}),
130+
Command.create({
131+
'role_id': role4.id,
132+
'user_ids': [self.user_projectuser.id],
133+
}),
134+
],
135+
})
136+
new_project = wizard._create_project_from_template()
137+
138+
self.assertEqual(
139+
new_project.task_ids.filtered(lambda t: t.name == 'Task 1').user_ids,
140+
self.user_projectuser + self.user_projectmanager + user2,
141+
'Task 1 should be assigned to the users mapped to `role1` and `role3`.',
142+
)
143+
self.assertEqual(
144+
new_project.task_ids.filtered(lambda t: t.name == 'Task 2').user_ids,
145+
self.user_projectuser,
146+
'Task 2 should be assigned to the users mapped to `role4`. As `role5` is not in the mapping.',
147+
)
148+
self.assertEqual(
149+
new_project.task_ids.filtered(lambda t: t.name == 'Task 3').user_ids,
150+
user1,
151+
'Task 3 should be assigned to the users mapped to `role2`. As `role5` is not in the mapping.',
152+
)
153+
self.assertEqual(
154+
new_project.task_ids.filtered(lambda t: t.name == 'Task 4').user_ids,
155+
user2,
156+
'Task 4 should be assigned to the users mapped to `role3`.'
157+
)
158+
self.assertFalse(
159+
new_project.task_ids.filtered(lambda t: t.name == 'Task 5').user_ids,
160+
'Task 5 should not be assigned to any user as `role5` is not in the mapping.',
161+
)
162+
self.assertFalse(
163+
new_project.task_ids.filtered(lambda t: t.name == 'Task 6').user_ids,
164+
'Task 6 should not be assigned to any user as it has no role.',
165+
)
166+
self.assertEqual(
167+
new_project.task_ids.filtered(lambda t: t.name == 'Task 7').user_ids,
168+
self.user_projectuser + self.user_projectmanager + user1 + user2,
169+
'Task 7 should be assigned to the users mapped to `role2` and `role3`, plus the users who were already assigned to the task.'
170+
)

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: 7 additions & 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>
@@ -1320,6 +1321,9 @@
13201321
<attribute name="required">is_template</attribute>
13211322
</field>
13221323
<field name="partner_id" position="replace"/>
1324+
<field name="user_ids" position="after">
1325+
<field name="role_ids" optional="hide" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}" invisible="not (is_template and has_project_template)"/>
1326+
</field>
13231327
</field>
13241328
</record>
13251329

@@ -1348,6 +1352,9 @@
13481352
<filter name="unassigned" position="after">
13491353
<filter name="task_templates" string="Task Templates" domain="[('has_project_template', '=', False)]" invisible="1"/>
13501354
</filter>
1355+
<field name="user_ids" position="after">
1356+
<field name="role_ids"/>
1357+
</field>
13511358
</field>
13521359
</record>
13531360

0 commit comments

Comments
 (0)