[MERGE] project: add task dependencies feature
Purpose
======
Sometimes, a task can only be completed once another is (e.g. 'install boiler' can only be done once 'order boiler' is complete). Communicating this information is key to organize a project and for the users to know on what they can start working when.
## Settings in Project App
- Add a 'Task Dependencies' setting under the 'Tasks Management' section
- Add the same setting on the project form view.
- As for the sub-tasks feature, enabling this feature in the settings of the app, should enable it on all existing and newly created projects with is_fsm = false.
## Task form view
- Add a 'Blocked by' notebook.
- In this notebook:
- add new many2many field called *depend_on_ids* containing the tasks in which the current task depends on these tasks with the following fields displayed by default: name, user_id, date_deadline, stage_id.
- add a 'view task' button on each line that should open the corresponding form view.
- Filter task list for depend_on_ids Many2many field.
- Check no cyclic task dependencies.
task-2387984
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr
closes odoo/odoo#66740
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -51,17 +51,15 @@
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<xpath expr="//div[@id='rating_settings']/.." position="before">
|
||||
<div class="row mt16 o_settings_container">
|
||||
<div class="col-lg-6 o_setting_box" id="timesheet_settings">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_timesheets"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_timesheets" string="Timesheets"/>
|
||||
<div class="text-muted">
|
||||
Log time on tasks
|
||||
</div>
|
||||
<xpath expr="//div[@id='rating_settings']" position="before">
|
||||
<div class="col-lg-6 o_setting_box" id="timesheet_settings">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_timesheets"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_timesheets" string="Timesheets"/>
|
||||
<div class="text-muted">
|
||||
Log time on tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,6 +194,15 @@
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='depend_on_ids']/tree//field[@name='company_id']" position="after">
|
||||
<field name="allow_subtasks" invisible="1" />
|
||||
<field name="planned_hours" widget="timesheet_uom_no_toggle" sum="Initially Planned Hours" optional="hide"/>
|
||||
<field name="effective_hours" widget="timesheet_uom" sum="Effective Hours" optional="hide"/>
|
||||
<field name="remaining_hours" widget="timesheet_uom" sum="Remaining Hours" optional="hide" decoration-danger="progress >= 100" decoration-warning="progress >= 80 and progress < 100"/>
|
||||
<field name="subtask_effective_hours" widget="timesheet_uom" attrs="{'invisible' : [('allow_subtasks', '=', False)]}" optional="hide"/>
|
||||
<field name="total_hours_spent" widget="timesheet_uom" attrs="{'invisible' : [('allow_subtasks', '=', False)]}" optional="hide"/>
|
||||
<field name="progress" widget="progressbar" optional="hide" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ class Project(models.Model):
|
||||
help="Project in which sub-tasks of the current project will be created. It can be the current project itself.")
|
||||
allow_subtasks = fields.Boolean('Sub-tasks', default=lambda self: self.env.user.has_group('project.group_subtask_project'))
|
||||
allow_recurring_tasks = fields.Boolean('Recurring Tasks', default=lambda self: self.env.user.has_group('project.group_project_recurring_tasks'))
|
||||
allow_task_dependencies = fields.Boolean('Task Dependencies', default=lambda self: self.env.user.has_group('project.group_project_task_dependencies'))
|
||||
|
||||
# rating fields
|
||||
rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True)
|
||||
@@ -648,6 +649,10 @@ class Task(models.Model):
|
||||
# customer portal: include comment and incoming emails in communication history
|
||||
website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])])
|
||||
|
||||
# Task Dependencies fields
|
||||
allow_task_dependencies = fields.Boolean(related='project_id.allow_task_dependencies')
|
||||
depend_on_ids = fields.Many2many('project.task', relation="task_dependencies_rel", column1="task_id", column2="depends_on_id", string="Blocked By", domain="[('allow_task_dependencies', '=', True), ('id', '!=', id)]")
|
||||
|
||||
# recurrence fields
|
||||
allow_recurring_tasks = fields.Boolean(related='project_id.allow_recurring_tasks')
|
||||
recurring_task = fields.Boolean(string="Recurrent")
|
||||
@@ -731,6 +736,11 @@ class Task(models.Model):
|
||||
repeat_show_week = fields.Boolean(compute='_compute_repeat_visibility')
|
||||
repeat_show_month = fields.Boolean(compute='_compute_repeat_visibility')
|
||||
|
||||
@api.constrains('depend_on_ids')
|
||||
def _check_no_cyclic_dependencies(self):
|
||||
if not self._check_m2m_recursion('depend_on_ids'):
|
||||
raise ValidationError(_("You cannot create cyclic dependency."))
|
||||
|
||||
@api.model
|
||||
def _get_recurrence_fields(self):
|
||||
return ['repeat_interval', 'repeat_unit', 'repeat_type', 'repeat_until', 'repeat_number',
|
||||
@@ -1155,7 +1165,7 @@ class Task(models.Model):
|
||||
if task.project_id.partner_id:
|
||||
task.partner_id = task.project_id.partner_id
|
||||
else:
|
||||
task.partner_id = task.project_id.partner_id or task.parent_id.partner_id
|
||||
task.partner_id = task.project_id.partner_id or task.parent_id.partner_id
|
||||
|
||||
@api.depends('partner_id.email', 'parent_id.email_from')
|
||||
def _compute_email_from(self):
|
||||
@@ -1376,6 +1386,16 @@ class Task(models.Model):
|
||||
# Actions
|
||||
# ------------
|
||||
|
||||
def action_open_task_form(self):
|
||||
""" Opens the form view of the task """
|
||||
return {
|
||||
'view_mode': 'form',
|
||||
'res_model': 'project.task',
|
||||
'res_id': self.id,
|
||||
'type': 'ir.actions.act_window',
|
||||
'context': dict(self._context, create=False)
|
||||
}
|
||||
|
||||
def action_recurring_tasks(self):
|
||||
return {
|
||||
'name': 'Tasks in Recurrence',
|
||||
|
||||
@@ -12,22 +12,36 @@ class ResConfigSettings(models.TransientModel):
|
||||
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
|
||||
group_project_rating = fields.Boolean("Customer Ratings", implied_group='project.group_project_rating')
|
||||
group_project_recurring_tasks = fields.Boolean("Recurring Tasks", implied_group="project.group_project_recurring_tasks")
|
||||
group_project_task_dependencies = fields.Boolean("Task Dependencies", implied_group="project.group_project_task_dependencies")
|
||||
|
||||
@api.model
|
||||
def _get_basic_project_domain(self):
|
||||
return []
|
||||
|
||||
def set_values(self):
|
||||
|
||||
# Ensure that settings on existing projects match the above fields
|
||||
projects = self.env["project.project"].search([])
|
||||
features = (
|
||||
# Pairs of associated (config_flag, project_flag)
|
||||
("group_subtask_project", "allow_subtasks"),
|
||||
("group_project_rating", "rating_active"),
|
||||
("group_project_recurring_tasks", "allow_recurring_tasks"),
|
||||
)
|
||||
for (config_flag, project_flag) in features:
|
||||
config_flag_global = "project." + config_flag
|
||||
config_feature_enabled = self[config_flag]
|
||||
if (self.user_has_groups(config_flag_global)
|
||||
is not config_feature_enabled):
|
||||
projects[project_flag] = config_feature_enabled
|
||||
global_features = { # key: config_flag, value: project_flag
|
||||
"group_project_rating": "rating_active",
|
||||
"group_project_recurring_tasks": "allow_recurring_tasks",
|
||||
}
|
||||
basic_project_features = {
|
||||
"group_subtask_project": "allow_subtasks",
|
||||
"group_project_task_dependencies": "allow_task_dependencies",
|
||||
}
|
||||
config_feature_vals = {config_flag: self[config_flag]
|
||||
for config_flag in global_features.keys() | basic_project_features.keys()}
|
||||
|
||||
def update_projects(projects, features):
|
||||
for (config_flag, project_flag) in features.items():
|
||||
config_flag_global = "project." + config_flag
|
||||
config_feature_enabled = config_feature_vals[config_flag]
|
||||
if self.user_has_groups(config_flag_global) != config_feature_enabled:
|
||||
projects[project_flag] = config_feature_enabled
|
||||
|
||||
# update for all projects
|
||||
update_projects(projects, global_features)
|
||||
# update for basic projects
|
||||
update_projects(projects.filtered_domain(self._get_basic_project_domain()), basic_project_features)
|
||||
|
||||
super(ResConfigSettings, self).set_values()
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
</record>
|
||||
|
||||
<record id="group_project_task_dependencies" model="res.groups">
|
||||
<field name="name">Use Task Dependencies</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
</record>
|
||||
|
||||
<data noupdate="1">
|
||||
<record id="base.default_user" model="res.users">
|
||||
<field name="groups_id" eval="[(4,ref('project.group_project_manager'))]"/>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.o_form_project_tasks div.o_field_many2many[name=depend_on_ids] tr.o_selected_row td.o_list_button {
|
||||
background-color: $table-border-color !important;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ from . import test_project_flow
|
||||
from . import test_project_recurrence
|
||||
from . import test_project_ui
|
||||
from . import test_multicompany
|
||||
from . import test_task_dependencies
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.fields import Command
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.project.tests.test_project_base import TestProjectCommon
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'task_dependencies')
|
||||
class TestTaskDependencies(TestProjectCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.project_pigs.write({
|
||||
'allow_task_dependencies': True,
|
||||
})
|
||||
cls.task_3 = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({
|
||||
'name': 'Pigs UserTask 2',
|
||||
'user_id': cls.user_projectuser.id,
|
||||
'project_id': cls.project_pigs.id,
|
||||
})
|
||||
|
||||
def test_task_dependencies(self):
|
||||
""" Test the task dependencies feature
|
||||
|
||||
Test Case:
|
||||
=========
|
||||
1) Add task2 as dependency in task1
|
||||
2) Checks if the task1 has the task in depend_on_ids field.
|
||||
"""
|
||||
self.assertEqual(len(self.task_1.depend_on_ids), 0, "The task 1 should not have any dependency.")
|
||||
self.task_1.write({
|
||||
'depend_on_ids': [Command.link(self.task_2.id)],
|
||||
})
|
||||
self.assertEqual(len(self.task_1.depend_on_ids), 1, "The task 1 should have a dependency.")
|
||||
self.task_1.write({
|
||||
'depend_on_ids': [Command.link(self.task_3.id)],
|
||||
})
|
||||
self.assertEqual(len(self.task_1.depend_on_ids), 2, "The task 1 should have two dependencies.")
|
||||
|
||||
def test_cyclic_dependencies(self):
|
||||
""" Test the cyclic dependencies
|
||||
|
||||
Test Case:
|
||||
=========
|
||||
1) Check initial setting on three tasks
|
||||
2) Add task2 as dependency in task1
|
||||
3) Add task3 as dependency in task2
|
||||
4) Add task1 as dependency in task3 and check a validation error is raised
|
||||
5) Add task1 as dependency in task2 and check a validation error is raised
|
||||
"""
|
||||
# 1) Check initial setting on three tasks
|
||||
self.assertTrue(
|
||||
len(self.task_1.depend_on_ids) == len(self.task_2.depend_on_ids) == len(self.task_3.depend_on_ids) == 0,
|
||||
"The three tasks should depend on no tasks.")
|
||||
self.assertTrue(self.task_1.allow_task_dependencies, 'The task dependencies feature should be enable.')
|
||||
self.assertTrue(self.task_2.allow_task_dependencies, 'The task dependencies feature should be enable.')
|
||||
self.assertTrue(self.task_3.allow_task_dependencies, 'The task dependencies feature should be enable.')
|
||||
|
||||
# 2) Add task2 as dependency in task1
|
||||
self.task_1.write({
|
||||
'depend_on_ids': [Command.link(self.task_2.id)],
|
||||
})
|
||||
self.assertEqual(len(self.task_1.depend_on_ids), 1, 'The task 1 should have one dependency.')
|
||||
|
||||
# 3) Add task3 as dependency in task2
|
||||
self.task_2.write({
|
||||
'depend_on_ids': [Command.link(self.task_3.id)],
|
||||
})
|
||||
self.assertEqual(len(self.task_2.depend_on_ids), 1, "The task 2 should have one dependency.")
|
||||
|
||||
# 4) Add task1 as dependency in task3 and check a validation error is raised
|
||||
with self.assertRaises(ValidationError), self.cr.savepoint():
|
||||
self.task_3.write({
|
||||
'depend_on_ids': [Command.link(self.task_1.id)],
|
||||
})
|
||||
self.assertEqual(len(self.task_3.depend_on_ids), 0, "The dependency should not be added in the task 3 because of a cyclic dependency.")
|
||||
|
||||
# 5) Add task1 as dependency in task2 and check a validation error is raised
|
||||
with self.assertRaises(ValidationError), self.cr.savepoint():
|
||||
self.task_2.write({
|
||||
'depend_on_ids': [Command.link(self.task_1.id)],
|
||||
})
|
||||
self.assertEqual(len(self.task_2.depend_on_ids), 1, "The number of dependencies should no change in the task 2 because of a cyclic dependency.")
|
||||
@@ -11,6 +11,7 @@
|
||||
<script type="text/javascript" src="/project/static/src/js/tours/project.js"></script>
|
||||
<script type="text/javascript" src="/project/static/src/js/project_calendar.js"></script>
|
||||
<link rel="stylesheet" type="text/scss" href="/project/static/src/scss/project_dashboard.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/project/static/src/scss/project_form.scss"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -383,6 +383,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 o_setting_box" id="task_dependencies_setting" groups="project.group_project_task_dependencies">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_task_dependencies"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_task_dependencies"/>
|
||||
<div class="text-muted">
|
||||
Determine the order in which to perform tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</page>
|
||||
</notebook>
|
||||
@@ -649,6 +660,7 @@
|
||||
<field name="repeat_show_week" invisible="1" />
|
||||
<field name="repeat_show_month" invisible="1" />
|
||||
<field name="recurrence_id" invisible="1" />
|
||||
<field name="allow_task_dependencies" invisible="1" />
|
||||
<header>
|
||||
<button name="action_assign_to_me" string="Assign to Me" type="object" class="oe_highlight"
|
||||
attrs="{'invisible' : [('user_id', '!=', False)]}"/>
|
||||
@@ -787,6 +799,23 @@
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page name="task_dependencies" string="Blocked By" attrs="{'invisible': [('allow_task_dependencies', '=', False)]}" groups="project.group_project_task_dependencies">
|
||||
<field name="depend_on_ids" nolabel="1">
|
||||
<tree editable="bottom">
|
||||
<field name="is_closed" invisible="1" />
|
||||
<field name="name" />
|
||||
<field name="project_id" optional="hide" />
|
||||
<field name="user_id" widget="many2one_avatar_user" optional="show"/>
|
||||
<field name="company_id" optional="hide" groups="base.group_multi_company" />
|
||||
<field name="activity_ids" widget="list_activity" optional="hide"/>
|
||||
<field name="date_deadline" attrs="{'invisible': [('is_closed', '=', True)]}" optional="show" />
|
||||
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" optional="hide"/>
|
||||
<field name="kanban_state" widget="state_selection" optional="hide" />
|
||||
<field name="stage_id" optional="show" />
|
||||
<button class="oe_link float-right" string="View Task" name="action_open_task_form" type="object"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
|
||||
@@ -59,6 +59,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="task_dependencies_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_project_task_dependencies"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_project_task_dependencies"/>
|
||||
<div class="text-muted">
|
||||
Determine the order in which to perform tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Time Management</h2>
|
||||
<div class="row mt16 o_settings_container" name="project_time">
|
||||
|
||||
@@ -40,17 +40,15 @@
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='timesheet_settings']/.." position="after">
|
||||
<div class="row mt16 o_settings_container">
|
||||
<div class="col-lg-6 o_setting_box" id="allow_billable_container">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_billable"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_billable"/>
|
||||
<div class="text-muted" id="allow_billable_setting">
|
||||
Invoice your time and material to customers
|
||||
</div>
|
||||
<xpath expr="//div[@id='timesheet_settings']" position="after">
|
||||
<div class="col-lg-6 o_setting_box" id="allow_billable_container">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_billable"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_billable"/>
|
||||
<div class="text-muted" id="allow_billable_setting">
|
||||
Invoice your time and material to customers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user