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

Closed
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
4 changes: 2 additions & 2 deletions addons/hr_timesheet/models/project_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ def action_view_tasks(self):
action['context']['allow_timesheets'] = self.allow_timesheets
return action

def action_create_from_template(self, values=None):
project = super().action_create_from_template(values)
def action_create_from_template(self, values=None, role_to_users_mapping=None):
project = super().action_create_from_template(values, role_to_users_mapping)
project._create_analytic_account()
return project
1 change: 1 addition & 0 deletions addons/project/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
'views/project_task_type_views.xml',
'views/project_project_views.xml',
'views/project_task_views.xml',
'views/project_role_views.xml',
'views/project_tags_views.xml',
'views/project_milestone_views.xml',
'views/res_partner_views.xml',
Expand Down
1 change: 1 addition & 0 deletions addons/project/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from . import project_task_stage_personal
from . import project_milestone
from . import project_project
from . import project_role
from . import project_task
from . import project_task_type
from . import project_tags
Expand Down
33 changes: 19 additions & 14 deletions addons/project/models/project_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,22 +473,20 @@ def map_tasks(self, new_project_id):
return True

def copy_data(self, default=None):
default = dict(default or {})
vals_list = super().copy_data(default=default)
if default and 'name' in default:
return vals_list
copy_from_template = self.env.context.get('copy_from_template')
for project, vals in zip(self, vals_list):
if project.is_template and not copy_from_template:
vals['is_template'] = True
if copy_from_template:
# We can make last_update_status as None because it is a required field
vals.pop("last_update_status", None)
for field in set(self._get_template_field_blacklist()) & set(vals.keys()):
del vals[field]
vals["name"] = project.name
for field in self._get_template_field_blacklist():
if field in vals and field not in default:
del vals[field]
if copy_from_template or (not project.is_template and vals.get('is_template')):
vals['name'] = default.get('name', project.name)
else:
if project.is_template or not vals.get("is_template"):
vals["name"] = self.env._("%s (copy)", project.name)
vals['name'] = default.get('name', self.env._('%s (copy)', project.name))
return vals_list

def copy(self, default=None):
Expand Down Expand Up @@ -1266,6 +1264,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.task_ids.role_ids = False

@api.model
def _get_template_default_context_whitelist(self):
Expand All @@ -1283,17 +1283,22 @@ def _get_template_field_blacklist(self):
"partner_id",
]

def action_create_from_template(self, values=None):
def action_create_from_template(self, values=None, role_to_users_mapping=None):
self.ensure_one()
values = values or {}
default = {
key.removeprefix('default_'): value
for key, value in self.env.context.items()
if key.startswith('default_') and key.removeprefix('default_') in self._get_template_default_context_whitelist()
} | values | {
field: False
for field in self._get_template_field_blacklist()
}
} | values
project = self.with_context(copy_from_template=True).copy(default=default)
project.message_post(body=self.env._("Project created from template %(name)s.", name=self.name))

# Tasks dispatching using project roles
project.task_ids.role_ids = False
if role_to_users_mapping and (mapping := role_to_users_mapping.filtered(lambda entry: entry.user_ids)):
for template_task, new_task in zip(self.task_ids, project.task_ids):
for entry in mapping:
if entry.role_id in template_task.role_ids:
new_task.user_ids |= entry.user_ids
return 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)]
2 changes: 2 additions & 0 deletions addons/project/models/project_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ def _read_group_personal_stage_type_ids(self, stages, domain):
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='Project 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 @@ -1938,6 +1939,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_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
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
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
119 changes: 119 additions & 0 deletions addons/project/tests/test_project_template.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from odoo.exceptions import UserError
from odoo import Command
from odoo.addons.project.tests.test_project_base import TestProjectCommon


Expand Down Expand Up @@ -49,3 +50,121 @@ def test_revert_task_template(self):
self.task_inside_template.is_template = True
with self.assertRaises(UserError, msg="A UserError should be raised when attempting to revert a template task to a regular one."):
self.task_inside_template.action_convert_to_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-to-users mapping defined
on the project template wizard.
"""
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',
'is_template': True,
'task_ids': [
Command.create({
'name': 'Task 1',
'role_ids': [role1.id, role3.id],
}),
Command.create({
'name': 'Task 2',
'role_ids': [role5.id, role4.id],
}),
Command.create({
'name': 'Task 3',
'role_ids': [role2.id, role5.id],
}),
Command.create({
'name': 'Task 4',
'role_ids': [role3.id],
}),
Command.create({
'name': 'Task 5',
'role_ids': [role5.id],
}),
Command.create({
'name': 'Task 6',
}),
Command.create({
'name': 'Task 7',
'role_ids': [role2.id, role3.id],
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
}),
],
})
user1, user2 = self.env['res.users'].create([
{
'name': 'Test User 1',
'login': 'test1',
'password': 'testuser1',
'email': '[email protected]',
},
{
'name': 'Test User 2',
'login': 'test2',
'password': 'testuser2',
'email': '[email protected]',
}
])
wizard = self.env['project.template.create.wizard'].create({
'template_id': project_template.id,
'name': 'New Project from Template',
'role_to_users_ids': [
Command.create({
'role_id': role1.id,
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
}),
Command.create({
'role_id': role2.id,
'user_ids': [user1.id],
}),
Command.create({
'role_id': role3.id,
'user_ids': [user2.id],
}),
Command.create({
'role_id': role4.id,
'user_ids': [self.user_projectuser.id],
}),
],
})
new_project = wizard._create_project_from_template()

self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 1').user_ids,
self.user_projectuser + self.user_projectmanager + user2,
'Task 1 should be assigned to the users mapped to `role1` and `role3`.',
)
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`. As `role5` is not in the mapping.',
)
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.',
)
self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 7').user_ids,
self.user_projectuser + self.user_projectmanager + user1 + user2,
'Task 7 should be assigned to the users mapped to `role2` and `role3`, plus the users who were already assigned to the task.'
)
5 changes: 5 additions & 0 deletions addons/project/views/project_menus.xml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@
id="project_menu_config_task_templates"
action="project_task_templates_action"
/>
<menuitem
name="Project Roles"
id="project_menu_config_project_roles"
action="project_roles_action"
/>
<menuitem
name="Activity Types"
id="project_menu_config_activity_type"
Expand Down
1 change: 1 addition & 0 deletions addons/project/views/project_project_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@
<kanban position="attributes">
<attribute name="js_class"></attribute>
</kanban>
<span name="partner_name" position="replace"/>
</field>
</record>

Expand Down
85 changes: 85 additions & 0 deletions addons/project/views/project_role_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_role_view_list" model="ir.ui.view">
<field name="name">project.role.list</field>
<field name="model">project.role</field>
<field name="arch" type="xml">
<list editable="bottom" multi_edit="1">
<field name="sequence" widget="handle"/>
<field name="name" placeholder="e.g. Developer"/>
<field name="color" widget="color_picker" optional="show"/>
</list>
</field>
</record>

<record id="project_role_view_form" model="ir.ui.view">
<field name="name">project.role.form</field>
<field name="model">project.role</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<group>
<field name="active" invisible="1"/>
<field name="name"/>
<field name="color" widget="color_picker"/>
</group>
</sheet>
</form>
</field>
</record>

<record id="project_role_view_kanban" model="ir.ui.view">
<field name="name">project.role.kanban</field>
<field name="model">project.role</field>
<field name="arch" type="xml">
<kanban highlight_color="color">
<templates>
<t t-name="menu">
<a t-if="widget.editable" role="menuitem" type="open" class="dropdown-item">Edit</a>
<a t-if="widget.deletable" role="menuitem" type="delete" class="dropdown-item">Delete</a>
<field name="color" widget="kanban_color_picker"/>
</t>
<t t-name="card">
<field name="name" class="fw-bold fs-4 ms-1"/>
</t>
</templates>
</kanban>
</field>
</record>

<record id="project_role_view_search" model="ir.ui.view">
<field name="name">project.role.search</field>
<field name="model">project.role</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
</search>
</field>
</record>

<record id="project_roles_action" model="ir.actions.act_window">
<field name="name">Project Roles</field>
<field name="res_model">project.role</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="project_role_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No project role found. Let's create one!
</p>
</field>
</record>

<record id="project_roles_action_list" model="ir.actions.act_window.view">
<field name="act_window_id" ref="project_roles_action"/>
<field name="view_mode">list</field>
<field name="view_id" ref="project.project_role_view_list"/>
</record>

<record id="project_roles_action_kanban" model="ir.actions.act_window.view">
<field name="act_window_id" ref="project_roles_action"/>
<field name="view_mode">kanban</field>
<field name="view_id" ref="project.project_role_view_kanban"/>
</record>
</odoo>
7 changes: 7 additions & 0 deletions addons/project/views/project_task_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,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 and has_project_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 Expand Up @@ -1320,6 +1321,9 @@
<attribute name="required">is_template</attribute>
</field>
<field name="partner_id" position="replace"/>
<field name="user_ids" position="after">
<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)"/>
</field>
</field>
</record>

Expand Down Expand Up @@ -1348,6 +1352,9 @@
<filter name="unassigned" position="after">
<filter name="task_templates" string="Task Templates" domain="[('has_project_template', '=', False)]" invisible="1"/>
</filter>
<field name="user_ids" position="after">
<field name="role_ids"/>
</field>
</field>
</record>

Expand Down
Loading