[IMP] project: set parent milestone on subtasks

As sub-tasks are a sub-set of a task that should logically be completed
for the parent task to be completed, it would make sense for the
sub-tasks to share the same milestone as their parent task by default.

The milestone of a parent task is automatically set to its subtasks if:
- The subtask has no milestone set
- AND They belong to the same project or the subtask has no project set

- OR they shared the same milestone before the change on the parent
task (side effect for that case: if an invalide milestone is set on the
parent task, both parent task's and subtasks' milestones which are equal
are gonna be reset)

task-3450281

closes odoo/odoo#130439

Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
Hugo Carlier (Huca)
2023-08-11 15:52:44 +02:00
parent 6303a3eacd
commit 62e53fa7b1
3 changed files with 279 additions and 14 deletions
+1 -5
View File
@@ -384,6 +384,7 @@ class Project(models.Model):
'name': task.name,
'state': task.state,
'company_id': project.company_id.id,
'project_id': project.id,
}
def map_tasks(self, new_project_id):
@@ -399,11 +400,6 @@ class Project(models.Model):
defaults = self._map_tasks_default_valeus(task, project)
new_tasks |= task.copy(defaults)
project.write({'tasks': [Command.set(new_tasks.ids)]})
new_tasks._get_all_subtasks().filtered(
lambda child: child.project_id == self
).write({
'project_id': project.id
})
return True
@api.returns('self', lambda value: value.id)
+42 -2
View File
@@ -998,6 +998,46 @@ class Task(models.Model):
# unset the parent => "I want to display the task back in the project"
# => set `display_in_project` to True
vals['display_in_project'] = True
if 'milestone_id' in vals:
# WARNING: has to be done after 'project_id' vals is written on subtasks
milestone = self.env['project.milestone'].browse(vals['milestone_id'])
# 1. Task for which the milestone is unvalid -> milestone_id is reset
if 'project_id' not in vals:
unvalid_milestone_tasks = self.filtered(lambda task: task.project_id != milestone.project_id) if vals['milestone_id'] else self.env['project.task']
else:
unvalid_milestone_tasks = self if not vals['milestone_id'] or milestone.project_id.id != vals['project_id'] else self.env['project.task']
valid_milestone_tasks = self - unvalid_milestone_tasks
if unvalid_milestone_tasks:
unvalid_milestone_tasks.write({'milestone_id': False})
if valid_milestone_tasks:
valid_milestone_tasks.write({'milestone_id': vals['milestone_id']})
del vals['milestone_id']
# 2. Parent's milestone is set to subtask with no milestone recursively
subtasks_to_update = valid_milestone_tasks.child_ids.filtered(
lambda task: (task not in self and \
not task.milestone_id and \
task.project_id == milestone.project_id and \
task.state not in CLOSED_STATES))
# 3. If parent and child task share the same milestone, child task's milestone is updated when the parent one is changed
# No need to check if state is changed in vals as it won't affect the subtasks selected for update
if 'project_id' not in vals:
subtasks_to_update |= valid_milestone_tasks.child_ids.filtered(
lambda task: (task not in self and \
task.milestone_id == task.parent_id.milestone_id and \
task.state not in CLOSED_STATES))
else:
subtasks_to_update |= valid_milestone_tasks.child_ids.filtered(
lambda task: (task not in self and \
(not task.display_in_project or task.project_id.id == vals['project_id']) and \
task.milestone_id == task.parent_id.milestone_id and \
task.state not in CLOSED_STATES))
if subtasks_to_update:
subtasks_to_update.write({'milestone_id': vals['milestone_id']})
# stage change: update date_last_stage_update
now = fields.Datetime.now()
if 'stage_id' in vals:
@@ -1103,8 +1143,8 @@ class Task(models.Model):
@api.depends('project_id')
def _compute_milestone_id(self):
for task in self:
if (task.project_id or task.parent_id.project_id) != task.milestone_id.project_id:
task.milestone_id = False
if task.project_id != task.milestone_id.project_id:
task.milestone_id = task.parent_id.project_id == task.project_id and task.parent_id.milestone_id
def _compute_has_late_and_unreached_milestone(self):
if all(not task.allow_milestones for task in self):
+236 -7
View File
@@ -11,10 +11,13 @@ class TestProjectMilestone(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.milestone = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
cls.milestone_pigs, cls.milestone_goats = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create([{
'name': 'Test Milestone',
'project_id': cls.project_pigs.id,
})
}, {
'name': 'Test Milestone Goats',
'project_id': cls.project_goats.id,
}])
def test_milestones_settings_change(self):
# To be sure the feature is disabled globally to begin the test.
@@ -36,20 +39,62 @@ class TestProjectMilestone(TestProjectCommon):
self.assertTrue(project_form.allow_milestones, 'New projects allow_milestones should be True by default.')
def test_change_project_in_task(self):
""" Test when a task is linked to a milestone and when we change its project the milestone is removed
""" Test when a task is linked to a milestone and when we change its project the milestone is removed (and
we fallback on the parent milestone if it belongs to the same project)
Test Case:
=========
1) Set a milestone on the task
2) Change the project of that task
3) Check no milestone is linked to the task
3) Check no milestone is linked to the task (or the one of its parent is used if relevant)
"""
self.task_1.milestone_id = self.milestone
self.assertEqual(self.task_1.milestone_id, self.milestone)
# A. No parent task
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.task_1.project_id = self.project_goats
self.assertFalse(self.task_1.milestone_id, 'No milestone should be linked to the task since its project has changed')
# B. Parent task with no milestone set
task_2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Child MilestoneTask',
'user_ids': self.user_projectmanager,
'project_id': self.project_pigs.id,
'parent_id': self.task_1.id,
'milestone_id': self.milestone_pigs.id,
})
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
task_2.project_id = self.project_goats
self.assertFalse(task_2.milestone_id, 'No milestone should be linked to the task since its project has changed and its parent task has no milestone')
# C. Parent task with a milestone set but on a different project
self.task_1.project_id = self.project_pigs
self.task_1.milestone_id = self.milestone_pigs
task_2.project_id = self.project_pigs
task_2.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
task_2.project_id = self.project_goats
self.assertFalse(task_2.milestone_id, 'No milestone should be linked to the task since its project has changed and its parent task belongs to another project')
# D. Parent task with a milestone set on the same project
self.task_1.project_id = self.project_goats
self.task_1.milestone_id = self.milestone_goats
task_2.project_id = self.project_pigs
task_2.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
task_2.project_id = self.project_goats
self.assertEqual(task_2.milestone_id, self.milestone_goats,
'The milestone of the task should be replaced by the one of its parent task as they now belong to the same project')
# E. No milestone for private task
task_2.parent_id = False
self.assertEqual(task_2.milestone_id, self.milestone_goats)
task_2.project_id = False
self.assertFalse(task_2.milestone_id, 'No milestone should be linked to a private task')
def test_duplicate_project_duplicates_milestones_on_tasks(self):
"""
Test when we duplicate the project with tasks linked to its' milestones,
@@ -96,4 +141,188 @@ class TestProjectMilestone(TestProjectCommon):
self.assertNotEqual(copied_task.milestone_id, False,
"We should copy the milestone and it shouldn't be reset to false from _compute_milestone_id")
self.assertEqual(orig_task.milestone_id.name, copied_task.milestone_id.name,
"the copied milestone should be a copy if the original ")
"the copied milestone should be a copy of the original ")
def test_basic_milestone_write(self):
""" Testing basic milestone/project write operation on task, i.e:
1. Set/change the milestone of a task
2. Change the milestone/project of a task simultaneously
3. Set/change to an invalid milestone
"""
extra_milestone_pigs = self.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Extra Milestone',
'project_id': self.project_pigs.id,
})
# 1. Set/change the milestone of a task
self.task_1.project_id = self.project_pigs
self.assertEqual(self.task_1.project_id, self.project_pigs)
self.assertFalse(self.task_1.milestone_id)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs,
"Assignation of a valid milestone to a task with no milestone is not working properly.")
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs,
"Change of the milestone of a task to a milestone from the same project is not working properly.")
# 2. Change the milestone/project of a task simultaneously
self.assertEqual(self.task_1.project_id, self.project_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'milestone_id': self.milestone_goats.id,
'project_id': self.project_goats.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_goats,
"Changing the project of a task and its milestone simultaneously is not working properly.")
self.assertEqual(self.task_1.project_id, self.project_goats,
"Changing the project of a task and its milestone simultaneously is not working properly.")
# 3. Set/change to an invalid milestone
self.assertEqual(self.task_1.project_id, self.project_goats)
self.assertEqual(self.task_1.milestone_id, self.milestone_goats)
self.task_1.milestone_id = self.milestone_pigs
self.assertFalse(self.task_1.milestone_id,
"Setting the milestone of a task to an invalid value should reset the value of milestone_id.")
def test_set_milestone_parent_task(self):
""" When a milestone is set on a parent task, it is set as well on its child tasks if they have no milestone set yet and
if they belong to the same project (or they have no project set).
Test Case:
=========
1) Set a milestone on the task (or not)
2) Change the milestone of its parent
3) Check the result
"""
# A. Child task with no milestone set and belonging to the same project
task_2, task_3 = self.env['project.task'].with_context({'mail_create_nolog': True}).create([{
'name': 'Child MilestoneTask',
'user_ids': self.user_projectmanager,
'project_id': self.project_pigs.id,
'parent_id': self.task_1.id,
}, {
'name': 'Grand-child MilestoneTask',
'user_ids': self.user_projectmanager,
'project_id': self.project_pigs.id,
}])
self.assertFalse(self.task_1.milestone_id)
self.assertFalse(task_2.milestone_id)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs,
"The milestone of the parent task should be set to its subtasks if they belong to the same project (or the subtask has not project set) and the subtask has no milestone already set.")
# B. Child task with a milestone already set
extra_milestone_pigs = self.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Extra Milestone',
'project_id': self.project_pigs.id,
})
self.task_1.milestone_id = False
task_2.milestone_id = extra_milestone_pigs
self.assertFalse(self.task_1.milestone_id)
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, extra_milestone_pigs, "The milestone of the child task should not be modified has it has already one set.")
# C. Child task with no milestone set but belonging to another project
self.task_1.project_id = self.project_goats
task_2.milestone_id = False
self.assertFalse(self.task_1.milestone_id)
self.assertFalse(task_2.milestone_id)
self.task_1.milestone_id = self.milestone_goats
self.assertFalse(task_2.milestone_id, "The milestone of the parent task should not be set to its child task has they belong to different projects.")
# D. Recursion test (grand-parent task's milestone set to grand-child task)
task_3.parent_id = task_2
self.task_1.project_id = task_2.project_id = task_3.project_id = self.project_pigs
self.task_1.milestone_id = task_2.milestone_id = task_3.milestone_id = False
self.assertFalse(task_2.milestone_id)
self.assertFalse(task_3.milestone_id)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_3.milestone_id, self.milestone_pigs, "The milestone of the parent task should be set to its (grand)child tasks recursively.")
# E. Recursion test 2 (grand-child task has no milestone but first level child does)
self.task_1.milestone_id = False
task_2.milestone_id = self.milestone_pigs
task_3.milestone_id = False
self.assertFalse(self.task_1.milestone_id)
self.assertFalse(task_3.milestone_id)
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.assertFalse(task_3.milestone_id,
"The milestone of the parent task should be set to its (grand)child tasks recursively. If a child task milestone should not be updated, it stops the recursion.")
# F. Update of the parent's milestone, trigger the update of the subtask's milestone if they were the same before change
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(task_2.milestone_id, extra_milestone_pigs,
"If parent and child tasks share the same milestone, the update of the parent's milestone should trigger the update of its child's milestone.")
# G. Same as F but project and milestone of the parent task are changed at the same time -> The milestone of the child should not change
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'project_id': self.project_goats.id,
'milestone_id': self.milestone_goats.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_goats)
self.assertEqual(task_2.milestone_id, extra_milestone_pigs,
"The child milestone should not be updated if the parent task's project is changed.")
# H. Same as G but project the project writen value is the same as the previous one -> No actual change of project_id so update the subtask milestone
self.task_1.project_id = self.project_pigs
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'project_id': self.project_pigs.id,
'milestone_id': self.milestone_pigs.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.assertEqual(task_2.milestone_id, self.milestone_pigs,
"The child milestone should be updated as the project of the parent task does not actually change.")
# I. Same case as G but the display_on_project is set to False on the child task -> Both project and milestone of the subtask should be updated
self.task_1.write({
'project_id': self.project_pigs.id,
'milestone_id': extra_milestone_pigs.id,
})
task_2.display_in_project = False
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'project_id': self.project_goats.id,
'milestone_id': self.milestone_goats.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_goats)
self.assertEqual(task_2.project_id, self.project_goats)
self.assertEqual(task_2.milestone_id, self.milestone_goats,
"The child milestone should be updated if the parent task's project is changed only if dislay_on_project is set to False for the subtask.")
# J. Same case as F but subtask is closed -> no update of its milestone
task_2.state = '1_done'
self.task_1.project_id = task_2.project_id = self.project_pigs
self.task_1.milestone_id = task_2.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.task_1.write({
'milestone_id': extra_milestone_pigs.id,
})
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.assertEqual(task_2.milestone_id, self.milestone_pigs,
"The child milestone should not be updated if it is closed.")