Skip to content

[IMP] project: dispatch tasks based on project roles #4790

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: master-imp-project-templates-kthe
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions addons/project/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions addons/project/models/project_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions addons/project/models/project_role.py
Original file line number Diff line number Diff line change
@@ -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)]
21 changes: 21 additions & 0 deletions addons/project/models/project_role_user_map.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion addons/project/models/project_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions addons/project/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
114 changes: 114 additions & 0 deletions addons/project/tests/test_project_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': '[email protected]',
},
{
'name': 'Test User 2',
'login': 'test2',
'password': 'test2',
'email': '[email protected]',
}
])
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.',
)
8 changes: 8 additions & 0 deletions addons/project/views/project_project_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@
</group>
</group>
</page>
<page name="role_user_mapping" string="Roles" invisible="not is_template or not show_project_roles">
<field name="role_user_ids" mode="list">
<list editable="bottom">
<field name="role_id" options="{'no_create': True}"/>
<field name="user_ids" widget="many2many_avatar_user"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter reload_on_follower="True"/>
Expand Down
3 changes: 2 additions & 1 deletion addons/project/views/project_task_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@
<group>
<group>
<field name="project_id"
domain="[('active', '=', True), '|', ('company_id', '=', False), ('company_id', '=?', company_id)]"
domain="[('active', '=', True), '|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_template', '=', is_template)]"
required="parent_id or child_ids or is_template"
widget="project"/>
<field name="milestone_id"
Expand All @@ -416,6 +416,7 @@
class="o_task_user_field"
options="{'no_open': True, 'no_quick_create': True}"
widget="many2many_avatar_user"/>
<field name="role_ids" invisible="not is_template" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}" placeholder="Assign at project creation"/>
<field name="priority" widget="priority_switch"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}" context="{'project_id': project_id}"/>
</group>
Expand Down