diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py
index 75872e5f323..6df8d13fcc9 100644
--- a/addons/project/__manifest__.py
+++ b/addons/project/__manifest__.py
@@ -36,6 +36,7 @@
'views/res_config_settings_views.xml',
'views/mail_activity_views.xml',
'views/project_portal_templates.xml',
+ 'views/project_task_templates.xml',
'data/ir_cron_data.xml',
'data/mail_data.xml',
'data/mail_template_data.xml',
diff --git a/addons/project/data/mail_data.xml b/addons/project/data/mail_data.xml
index 25310bbd0e9..e6dc9a9218a 100644
--- a/addons/project/data/mail_data.xml
+++ b/addons/project/data/mail_data.xml
@@ -31,6 +31,12 @@
project.task
+
+ Task Dependency Changes
+ project.task
+
+
+
Task Created
@@ -72,4 +78,13 @@
project_id
+
+ Task Dependency Changes
+ 15
+ project.project
+
+
+ project_id
+
+
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index c7902132f9e..16512b2dd7d 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -8,7 +8,7 @@ from datetime import timedelta, datetime
from random import randint
from odoo import api, fields, models, tools, SUPERUSER_ID, _
-from odoo.exceptions import UserError, AccessError, ValidationError, RedirectWarning
+from odoo.exceptions import UserError, ValidationError
from odoo.osv.expression import OR
from .project_task_recurrence import DAYS, WEEKS
@@ -696,19 +696,19 @@ class Task(models.Model):
stage_id = fields.Many2one('project.task.type', string='Stage', compute='_compute_stage_id',
store=True, readonly=False, ondelete='restrict', tracking=True, index=True,
default=_get_default_stage_id, group_expand='_read_group_stage_ids',
- domain="[('project_ids', '=', project_id)]", copy=False)
+ domain="[('project_ids', '=', project_id)]", copy=False, task_dependency_tracking=True)
tag_ids = fields.Many2many('project.tags', string='Tags')
kanban_state = fields.Selection([
('normal', 'In Progress'),
('done', 'Ready'),
('blocked', 'Blocked')], string='Status',
copy=False, default='normal', required=True)
- kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State Label', tracking=True)
+ kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State Label', tracking=True, task_dependency_tracking=True)
create_date = fields.Datetime("Created On", readonly=True, index=True)
write_date = fields.Datetime("Last Updated On", readonly=True, index=True)
date_end = fields.Datetime(string='Ending Date', index=True, copy=False)
date_assign = fields.Datetime(string='Assigning Date', index=True, copy=False, readonly=True)
- date_deadline = fields.Date(string='Deadline', index=True, copy=False, tracking=True)
+ date_deadline = fields.Date(string='Deadline', index=True, copy=False, tracking=True, task_dependency_tracking=True)
date_last_stage_update = fields.Datetime(string='Last Stage Update',
index=True,
copy=False,
@@ -1103,6 +1103,19 @@ class Task(models.Model):
)
return super(Task, self).get_empty_list_help(help)
+ def _valid_field_parameter(self, field, name):
+ # If the field has `task_dependency_tracking` on we track the changes made in the dependent task on the parent task
+ return name == 'task_dependency_tracking' or super()._valid_field_parameter(field, name)
+
+ @tools.ormcache('self.env.uid', 'self.env.su')
+ def _get_depends_tracked_fields(self):
+ """ Returns the set of tracked field names for the current model.
+ Those fields are the ones tracked in the parent task when using task dependencies.
+
+ See :meth:`mail.models.MailThread._get_tracked_fields`"""
+ fields = {name for name, field in self._fields.items() if getattr(field, 'task_dependency_tracking', None)}
+ return fields and set(self.fields_get(fields))
+
# ----------------------------------------
# Case management
# ----------------------------------------
@@ -1305,6 +1318,35 @@ class Task(models.Model):
# Mail gateway
# ---------------------------------------------------
+ def _mail_track(self, tracked_fields, initial_values):
+ result = super()._mail_track(tracked_fields, initial_values)
+ changes, tracking_value_ids = result
+ # Track changes on depending tasks
+ depends_tracked_fields = self._get_depends_tracked_fields()
+ depends_changes = changes & depends_tracked_fields
+ if depends_changes and self.user_has_groups('project.group_project_task_dependencies') and self.allow_task_dependencies:
+ parent_ids = self.env['project.task'].search([('depend_on_ids', 'in', self.ids)])
+ if parent_ids:
+ fields_to_ids = self.env['ir.model.fields']._get_ids('project.task')
+ field_ids = [fields_to_ids.get(name) for name in depends_changes]
+ depends_tracking_value_ids = [
+ tracking_values for tracking_values in tracking_value_ids
+ if tracking_values[2]['field'] in field_ids
+ ]
+ subtype = self.env.ref('project.mt_task_dependency_change')
+ # We want to include the original subtype message coming from the child task
+ # for example when the stage changes the message in the chatter starts with 'Stage Changed'
+ child_subtype = self._track_subtype(dict((col_name, initial_values[col_name]) for col_name in changes))
+ child_subtype_info = child_subtype.description or child_subtype.name if child_subtype else False
+ # NOTE: the subtype does not have a description on purpose, otherwise the description would be put
+ # at the end of the message instead of at the top, we use the name here
+ body = self.env['ir.qweb']._render('project.task_track_depending_tasks', {
+ 'child': self,
+ 'child_subtype': child_subtype_info,
+ })
+ parent_ids.message_post(body=body, subtype_id=subtype.id, tracking_value_ids=depends_tracking_value_ids)
+ return result
+
def _track_template(self, changes):
res = super(Task, self)._track_template(changes)
test_task = self[0]
diff --git a/addons/project/models/res_config_settings.py b/addons/project/models/res_config_settings.py
index 13d4837002a..1db31a61037 100644
--- a/addons/project/models/res_config_settings.py
+++ b/addons/project/models/res_config_settings.py
@@ -44,4 +44,11 @@ class ResConfigSettings(models.TransientModel):
# update for basic projects
update_projects(projects.filtered_domain(self._get_basic_project_domain()), basic_project_features)
+ # Hide the task dependency changes subtype when the dependency setting is disabled
+ task_dep_change_subtype_id = self.env.ref('project.mt_task_dependency_change')
+ project_task_dep_change_subtype_id = self.env.ref('project.mt_project_task_dependency_change')
+ if task_dep_change_subtype_id.hidden != (not self['group_project_task_dependencies']):
+ task_dep_change_subtype_id.hidden = not self['group_project_task_dependencies']
+ project_task_dep_change_subtype_id.hidden = not self['group_project_task_dependencies']
+
super(ResConfigSettings, self).set_values()
diff --git a/addons/project/tests/test_task_dependencies.py b/addons/project/tests/test_task_dependencies.py
index 42eda7fecb2..5ddc6e640c3 100644
--- a/addons/project/tests/test_task_dependencies.py
+++ b/addons/project/tests/test_task_dependencies.py
@@ -6,6 +6,7 @@ from odoo.tests import tagged
from odoo.addons.project.tests.test_project_base import TestProjectCommon
+from datetime import date
@tagged('-at_install', 'post_install', 'task_dependencies')
class TestTaskDependencies(TestProjectCommon):
@@ -23,6 +24,11 @@ class TestTaskDependencies(TestProjectCommon):
'project_id': cls.project_pigs.id,
})
+ def flush_tracking(self):
+ """ Force the creation of tracking values. """
+ self.env['base'].flush()
+ self.cr.precommit.run()
+
def test_task_dependencies(self):
""" Test the task dependencies feature
@@ -85,3 +91,39 @@ class TestTaskDependencies(TestProjectCommon):
'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.")
+
+ def test_tracking_dependencies(self):
+ # Enable the company setting
+ self.env['res.config.settings'].create({
+ 'group_project_task_dependencies': True
+ }).execute()
+ self.task_1.write({
+ 'depend_on_ids': [Command.link(self.task_2.id)]
+ })
+ self.cr.precommit.clear()
+ # Check that changing a dependency tracked field in task_2 logs a message in task_1.
+ self.task_2.write({'date_deadline': date(1983, 3, 1)}) # + 1 message in task_1 and task_2
+ self.flush_tracking()
+ self.assertEqual(len(self.task_1.message_ids), 1,
+ 'Changing the deadline on task 2 should have logged a message in task 1.')
+
+ # Check that changing a dependency tracked field in task_1 does not log a message in task_2.
+ self.task_1.date_deadline = date(2020, 1, 2) # + 1 message in task_1
+ self.flush_tracking()
+ self.assertEqual(len(self.task_2.message_ids), 1,
+ 'Changing the deadline on task 1 should not have logged a message in task 2.')
+
+ # Check that changing a field that is not tracked at all on task 2 does not impact task 1.
+ self.task_2.color = 100 # no new message
+ self.flush_tracking()
+ self.assertEqual(len(self.task_1.message_ids), 2,
+ 'Changing the color on task 2 should not have logged a message in task 1 since it is not tracked.')
+
+ # Check that changing multiple fields does not log more than one message.
+ self.task_2.write({
+ 'date_deadline': date(2020, 1, 1),
+ 'kanban_state': 'blocked',
+ }) # + 1 message in task_1 and task_2
+ self.flush_tracking()
+ self.assertEqual(len(self.task_1.message_ids), 3,
+ 'Changing multiple fields on task 2 should only log one message in task 1.')
diff --git a/addons/project/views/project_task_templates.xml b/addons/project/views/project_task_templates.xml
new file mode 100644
index 00000000000..6bfef7c0e09
--- /dev/null
+++ b/addons/project/views/project_task_templates.xml
@@ -0,0 +1,10 @@
+
+
+
+ Task:
+
+
+
+
+
+