From 6b3c5370a82d3f69ea90fd9e4cde176172fc8511 Mon Sep 17 00:00:00 2001 From: "Audric Onockx (auon)" Date: Thu, 25 May 2023 14:50:43 +0000 Subject: [PATCH] [IMP] hr_timesheet: allow timesheeting on sub-tasks with no project Allow timesheeting on sub-tasks with no project_id set. Instead, refer to the project_id set on its parent_id, and so on, recursively. task-3336215 X-original-commit: d794e41619a36a8c9ad1e77a816e57051207cdcf Part-of: odoo/odoo#124563 --- addons/hr_timesheet/models/hr_timesheet.py | 16 ++-- addons/hr_timesheet/models/project_task.py | 15 +++- addons/hr_timesheet/tests/test_timesheet.py | 65 +++++++++++++++- .../hr_timesheet/views/project_task_views.xml | 2 +- addons/project/models/project_task.py | 76 ++++++++++++++++++- addons/project/tests/test_project_base.py | 29 +++++++ addons/project/tests/test_project_subtasks.py | 34 +++++++++ addons/project/views/project_task_views.xml | 1 + .../models/account_analytic.py | 4 +- addons/sale_project/models/project.py | 4 +- addons/sale_project/tests/test_child_tasks.py | 34 ++++++++- .../sale_project/tests/test_sale_project.py | 2 +- .../sale_project/views/project_task_views.xml | 4 +- addons/sale_timesheet/models/project.py | 4 +- 14 files changed, 261 insertions(+), 29 deletions(-) diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index c9afedf781b..e3d7df5cdf6 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -50,7 +50,7 @@ class AccountAnalyticLine(models.Model): task_id = fields.Many2one( 'project.task', 'Task', index='btree_not_null', compute='_compute_task_id', store=True, readonly=False, - domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True), ('project_id', '=?', project_id)]") + domain="[('company_id', '=', company_id), ('project_root_id.allow_timesheets', '=', True), ('project_root_id', '=?', project_id)]") parent_task_id = fields.Many2one('project.task', related='task_id.parent_id', store=True) project_id = fields.Many2one( 'project.project', 'Project', domain=_domain_project_id, index=True, @@ -103,12 +103,12 @@ class AccountAnalyticLine(models.Model): if timesheet.project_id: timesheet.partner_id = timesheet.task_id.partner_id or timesheet.project_id.partner_id - @api.depends('task_id', 'task_id.project_id') + @api.depends('task_id') def _compute_project_id(self): for line in self: - if not line.task_id.project_id or line.project_id == line.task_id.project_id: + if not line.task_id.project_root_id or line.project_id == line.task_id.project_root_id: continue - line.project_id = line.task_id.project_id + line.project_id = line.task_id.project_root_id @api.depends('project_id') def _compute_task_id(self): @@ -120,7 +120,7 @@ class AccountAnalyticLine(models.Model): # TODO KBA in master - check to do it "properly", currently: # This onchange is used to reset the task_id when the project changes. # Doing it in the compute will remove the task_id when the project of a task changes. - if self.project_id != self.task_id.project_id: + if self.project_id != self.task_id.project_root_id: self.task_id = False @api.depends('employee_id') @@ -313,9 +313,9 @@ class AccountAnalyticLine(models.Model): tasks = self.env['project.task'].sudo().browse(task_ids) for task in tasks: task_per_id[task.id] = task - if not task.project_id: + if not task.project_root_id: raise ValidationError(_('Timesheets cannot be created on a private task.')) - account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_id.analytic_account_id.ids) + account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_root_id.analytic_account_id.ids) project_per_id = {} if project_ids: @@ -335,7 +335,7 @@ class AccountAnalyticLine(models.Model): vals = vals_list[index] data = task_per_id[vals['task_id']] if vals.get('task_id') else project_per_id[vals['project_id']] if not vals.get('project_id'): - vals['project_id'] = data.project_id.id + vals['project_id'] = data.project_root_id.id if not vals.get('account_id'): account = data._get_task_analytic_account_id() if vals.get('task_id') else data.analytic_account_id if not account or not account.active: diff --git a/addons/hr_timesheet/models/project_task.py b/addons/hr_timesheet/models/project_task.py index b112098af99..3ee502ad559 100644 --- a/addons/hr_timesheet/models/project_task.py +++ b/addons/hr_timesheet/models/project_task.py @@ -26,8 +26,9 @@ class Task(models.Model): _inherit = "project.task" project_id = fields.Many2one(domain=[('is_internal_project', '=', False)]) - analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True) - allow_timesheets = fields.Boolean("Allow timesheets", related='project_id.allow_timesheets', help="Timesheets can be logged on this task.", readonly=True) + project_root_id = fields.Many2one(domain=[('is_internal_project', '=', False)]) + analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True, recursive=True) + allow_timesheets = fields.Boolean("Allow timesheets", related="project_root_id.allow_timesheets", help="Timesheets can be logged on this task.", readonly=True, recursive=True) remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', store=True, readonly=True, help="Number of allocated hours minus the number of hours spent.") remaining_hours_percentage = fields.Float(compute='_compute_remaining_hours_percentage', search='_search_remaining_hours_percentage') effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True) @@ -54,7 +55,7 @@ class Task(models.Model): def _compute_encode_uom_in_days(self): self.encode_uom_in_days = self._uom_in_days() - @api.depends('analytic_account_id.active', 'project_id.analytic_account_id.active') + @api.depends('analytic_account_id.active', 'project_root_id.analytic_account_id.active') def _compute_analytic_account_active(self): """ Overridden in sale_timesheet """ for task in self: @@ -162,7 +163,13 @@ class Task(models.Model): def write(self, values): # a timesheet must have an analytic account (and a project) - if 'project_id' in values and not values.get('project_id') and self._get_timesheet(): + is_removed_project = 'project_id' in values and not values['project_id'] + is_removed_parent = 'parent_id' in values and not values['parent_id'] + if ( + ((is_removed_project and (not self.parent_id or is_removed_parent)) + or (is_removed_parent and not (self.project_id or is_removed_project))) + and self._get_timesheet() + ): raise UserError(_('This task must be part of a project because there are some timesheets linked to it.')) res = super(Task, self).write(values) diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index 023a866bbf7..197086c5348 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -484,16 +484,51 @@ class TestTimesheet(TestCommonTimesheet): 3) Enter the 8 hour timesheet in the child task 4) Check subtask Effective hours in parent task """ - self.task1.child_ids = [Command.set([self.task2.id])] - self.env['account.analytic.line'].create({ + subtask_with_project, subtask_without_project = self.env['project.task'].create([ + { + 'name': 'Subtask with project set', + 'project_id': self.project_customer.id, + }, + { + 'name': 'Suubtask without project set', + 'child_ids': [Command.create({'name': 'subsubtask without project set'})], + }, + ]) + subsubtask = subtask_without_project.child_ids + self.task1.child_ids = subtask_with_project + subtask_without_project + self.assertTrue(self.project_customer.allow_timesheets, 'The project should be timesheetable') + self.assertEqual(subtask_with_project.allow_timesheets, self.project_customer.allow_timesheets, 'The subtask should follow the settings of its project linked.') + Timesheet = self.env['account.analytic.line'] + Timesheet.create({ 'name': 'FirstTimeSheet', 'project_id': self.project_customer.id, - 'task_id': self.task2.id, + 'task_id': subtask_with_project.id, 'unit_amount': 8.0, 'employee_id': self.empl_employee2.id, }) self.assertEqual(self.task1.subtask_effective_hours, 8, 'Hours Spent on Sub-tasks should be 8 hours in Parent Task') - self.task1.child_ids = [Command.clear()] + + self.assertEqual(subtask_without_project.project_root_id, self.task1.project_id, 'The subtask without any project set should follow the settings of the project linked to its parent task.') + self.assertTrue(subtask_without_project.allow_timesheets, 'The subtask without any project set should be timesheetable because its parent task is timesheetable.') + + self.assertEqual(subsubtask.project_root_id, self.task1.project_id, 'The subtask without any project set should follow the first project linked to its ancestors.') + self.assertTrue(subsubtask.allow_timesheets, 'The subtask without any project set should be timesheetable because its ancestor is timesheetable.') + + Timesheet.create([ + { + 'name': '/', + 'task_id': subtask_without_project.id, + 'unit_amount': 1.0, + 'employee_id': self.empl_employee2.id, + }, + { + 'name': '/', + 'task_id': subsubtask.id, + 'unit_amount': 1.0, + 'employee_id': self.empl_employee2.id, + }, + ]) + self.assertEqual(self.task1.subtask_effective_hours, 10) def test_ensure_product_uom_set_in_timesheet(self): self.assertFalse(self.project_customer.timesheet_ids, 'No timesheet should be recorded in this project') @@ -608,3 +643,25 @@ class TestTimesheet(TestCommonTimesheet): self.task2.unlink() with self.assertRaises(RedirectWarning): self.task1.unlink() + + def test_cannot_convert_task_with_timesheets_in_private_task(self): + self.env['account.analytic.line'].create({ + 'name': '/', + 'unit_amount': 1, + 'project_id': self.project_customer.id, + 'task_id': self.task1.id, + 'employee_id': self.empl_employee.id, + }) + with self.assertRaises(UserError): + self.task1.project_id = False + + self.task1.parent_id = self.task2 + self.task1.project_id = False + + with self.assertRaises(UserError): + self.task1.parent_id = False + + self.task1.project_id = self.project_customer + + with self.assertRaises(UserError): + self.task1.write({'project_id': False, 'parent_id': False}) diff --git a/addons/hr_timesheet/views/project_task_views.xml b/addons/hr_timesheet/views/project_task_views.xml index 96a8bfe9eeb..8ac6e1aad95 100644 --- a/addons/hr_timesheet/views/project_task_views.xml +++ b/addons/hr_timesheet/views/project_task_views.xml @@ -38,7 +38,7 @@ You cannot log timesheets on this project since it is linked to an inactive analytic account. Please change this account, or reactivate the current one to timesheet on the project. - + diff --git a/addons/project/models/project_task.py b/addons/project/models/project_task.py index 0646ce0f07e..3901dcb7514 100644 --- a/addons/project/models/project_task.py +++ b/addons/project/models/project_task.py @@ -151,6 +151,7 @@ class Task(models.Model): project_id = fields.Many2one('project.project', string='Project', index=True, tracking=True, check_company=True, change_default=True) + project_root_id = fields.Many2one('project.project', compute='_compute_project_root_id', search='_search_project_root_id', recursive=True) task_properties = fields.Properties('Properties', definition='project_id.task_properties_definition', copy=True) planned_hours = fields.Float("Allocated Time", tracking=True) subtask_planned_hours = fields.Float("Sub-tasks Planned Hours", compute='_compute_subtask_planned_hours', @@ -290,6 +291,79 @@ class Task(models.Model): for task in self: task.is_private = not task.project_id and not task.parent_id + @api.depends('project_id', 'parent_id.project_id') + def _compute_project_root_id(self): + # project of the first ascendant that has one. Let's call it the root task. + for task in self: + task.project_root_id = task.project_id or task.parent_id.project_root_id + + def _search_project_root_id(self, operator, value): + if operator not in ("in", "not in", "any", "not any", "=", "!=", "=?"): + raise UserError(_("Unsupported operator for search on project_root_id")) + + where_query_1 = where_query_2 = "TRUE" + params = tuple() + if operator in ("any", "not any"): + query = self.env['project.project']._where_calc(value) + subquery_str, params = query.subselect() + where_operator = 'in' if operator == 'any' else 'not in' + where_query_1 = f""" + project_id {where_operator} ({subquery_str}) + """ + where_query_2 = f""" + project_root_id {'in' if operator == 'any' else 'not in'} ({subquery_str}) + """ + elif operator != "=?" or (value is not False and value is not None): + where_operator = operator if operator != '=?' else '=' + where_query_1 = f"project_id {where_operator} %s" + where_query_2 = f"project_root_id {where_operator} %s" + if isinstance(value, (list, tuple)): + params = (tuple(value),) + else: + params = (value,) + + self.env.cr.execute( + f""" + SELECT ARRAY_AGG(id) + FROM project_task + WHERE {where_query_1} + AND project_id IS NOT NULL + """, params + ) + task_ids = self._cr.fetchone()[0] or [] + + self.env.cr.execute( + f""" + WITH RECURSIVE project_hierarchy AS ( + SELECT pt.id, + pt.parent_id, + pt.project_id, + pt.project_id AS project_root_id + FROM project_task pt + WHERE pt.project_id IS NULL + AND pt.parent_id IS NOT NULL + + UNION ALL + + SELECT ph.id, + pt.parent_id, + ph.project_id, + COALESCE(ph.project_root_id, pt.project_id) AS project_root_id + FROM project_hierarchy ph + JOIN project_task pt ON ph.parent_id = pt.id + ) + SELECT ARRAY_AGG(id) + FROM project_hierarchy + WHERE {where_query_2} + """, params + ) + if not self._cr.rowcount and not task_ids: + return expression.FALSE_DOMAIN + task_ids += self.env.cr.fetchone()[0] or [] + if not task_ids: + return expression.FALSE_DOMAIN + return [('id', 'in', task_ids)] + def _search_is_private(self, operator, value): if not isinstance(value, bool): raise ValueError(_('Value should be True or False (not %s)'), value) @@ -1530,7 +1604,7 @@ class Task(models.Model): # --------------------------------------------------- def _get_task_analytic_account_id(self): self.ensure_one() - return self.analytic_account_id or self.project_id.analytic_account_id + return self.analytic_account_id or self.project_root_id.analytic_account_id @api.model def get_unusual_days(self, date_from, date_to=None): diff --git a/addons/project/tests/test_project_base.py b/addons/project/tests/test_project_base.py index 7c7c8027d1a..1807f83fcb0 100644 --- a/addons/project/tests/test_project_base.py +++ b/addons/project/tests/test_project_base.py @@ -197,3 +197,32 @@ class TestProjectBase(TestProjectCommon): with self.assertRaises(UserError): # Cannot change the company of a project if the company of the partner is different self.project_pigs.company_id = company_1 + + def test_search_project_root_id(self): + project = self.env['project.project'].create({ + 'name': 'Test project', + 'allow_milestones': False, + }) + ProjectTask = self.env['project.task'] + parent = ProjectTask.create({ + 'name': 'Test task', + 'project_id': project.id, + }) + child = ProjectTask.create({ + 'name': 'Test subtask', + 'parent_id': parent.id, + }) + tasks = parent | child + + other_projects = self.project_goats + self.project_pigs + + self.assertFalse(child.project_id) + self.assertEqual(child.project_root_id, parent.project_id) + self.assertEqual(parent.project_root_id, parent.project_id) + self.assertEqual(ProjectTask.search([('project_root_id', '=', project.id)]), tasks) + self.assertEqual(ProjectTask.search([('project_root_id', 'in', project.ids)]), tasks) + self.assertEqual(ProjectTask.search([('project_root_id.allow_milestones', '=', False)]), tasks) + self.assertEqual(ProjectTask.search(['!', ('project_root_id.allow_milestones', '=', True)]), tasks) + self.assertEqual(ProjectTask.search([('project_root_id', '=?', project.id)]), tasks) + self.assertEqual(ProjectTask.search([('project_root_id', 'not in', other_projects.ids), ('id', 'in', tasks.ids)]), tasks) + self.assertEqual(ProjectTask.search([('project_root_id', '!=', self.project_pigs.id), ('id', 'in', tasks.ids)]), tasks) diff --git a/addons/project/tests/test_project_subtasks.py b/addons/project/tests/test_project_subtasks.py index 5e4264f62af..ccd45f93330 100644 --- a/addons/project/tests/test_project_subtasks.py +++ b/addons/project/tests/test_project_subtasks.py @@ -254,3 +254,37 @@ class TestProjectSubtasks(TestProjectCommon): self.assertEqual(task.subtask_count, 1, "Parent task should have 1 children") task_2 = task.copy() self.assertEqual(task_2.subtask_count, 1, "If the parent task is duplicated then the sub task should be copied") + + def test_project_root_id(self): + task = self.env['project.task'].create({ + 'name': 'Task 1', + 'project_id': self.project_goats.id, + 'child_ids': [ + Command.create({ + 'name': 'Subtask 1', + 'child_ids': [Command.create({'name': 'Subsubtask 1'})], + }), + ], + }) + subtask = task.child_ids + subsubtask = subtask.child_ids + self.assertTrue(bool(subtask)) + self.assertTrue(bool(subsubtask)) + self.assertEqual(task.project_root_id, task.project_id) + self.assertFalse(subtask.project_id) + self.assertEqual(subtask.project_root_id, task.project_id) + self.assertFalse(subsubtask.project_id) + self.assertEqual(subsubtask.project_root_id, task.project_id) + + subtask.project_id = self.project_pigs + self.assertNotEqual(subtask.project_root_id, task.project_id) + self.assertEqual(subtask.project_root_id, self.project_pigs) + self.assertFalse(subsubtask.project_id) + self.assertNotEqual(subsubtask.project_root_id, task.project_id) + self.assertEqual(subsubtask.project_root_id, subtask.project_id) + + subtask.project_id = False + subsubtask.project_id = self.project_pigs + self.assertEqual(subtask.project_root_id, task.project_id) + self.assertNotEqual(subsubtask.project_root_id, task.project_id) + self.assertEqual(subsubtask.project_root_id, self.project_pigs) diff --git a/addons/project/views/project_task_views.xml b/addons/project/views/project_task_views.xml index 6ea48d8f5b2..1643582f076 100644 --- a/addons/project/views/project_task_views.xml +++ b/addons/project/views/project_task_views.xml @@ -250,6 +250,7 @@ +
diff --git a/addons/project_timesheet_holidays/models/account_analytic.py b/addons/project_timesheet_holidays/models/account_analytic.py index ccc00ba1c89..a1fa4a7b1c1 100644 --- a/addons/project_timesheet_holidays/models/account_analytic.py +++ b/addons/project_timesheet_holidays/models/account_analytic.py @@ -10,8 +10,8 @@ class AccountAnalyticLine(models.Model): holiday_id = fields.Many2one("hr.leave", string='Time Off Request', copy=False) global_leave_id = fields.Many2one("resource.calendar.leaves", string="Global Time Off", ondelete='cascade') - task_id = fields.Many2one(domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True)," - "('project_id', '=?', project_id), ('is_timeoff_task', '=', False)]") + task_id = fields.Many2one(domain="[('company_id', '=', company_id), ('project_root_id.allow_timesheets', '=', True)," + "('project_root_id', '=?', project_id), ('is_timeoff_task', '=', False)]") def _get_redirect_action(self): leave_form_view_id = self.env.ref('hr_holidays.hr_leave_view_form').id diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 179d7412c7e..47c6216dbc3 100755 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -696,9 +696,9 @@ class ProjectTask(models.Model): help="Sales Order Item to which the time spent on this task will be added in order to be invoiced to your customer.\n" "By default the sales order item set on the project will be selected. In the absence of one, the last prepaid sales order item that has time remaining will be used.\n" "Remove the sales order item in order to make this task non billable. You can also change or remove the sales order item of each timesheet entry individually.") - project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_id.sale_order_id') + project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_root_id.sale_order_id') task_to_invoice = fields.Boolean("To invoice", compute='_compute_task_to_invoice', search='_search_task_to_invoice', groups='sales_team.group_sale_salesman_all_leads') - allow_billable = fields.Boolean(related="project_id.allow_billable") + allow_billable = fields.Boolean(related="project_root_id.allow_billable") # Project sharing fields display_sale_order_button = fields.Boolean(string='Display Sales Order', compute='_compute_display_sale_order_button') diff --git a/addons/sale_project/tests/test_child_tasks.py b/addons/sale_project/tests/test_child_tasks.py index 8e86663bb4e..bb73f8f083a 100644 --- a/addons/sale_project/tests/test_child_tasks.py +++ b/addons/sale_project/tests/test_child_tasks.py @@ -46,8 +46,8 @@ class TestNestedTaskUpdate(TransactionCase): self.assertTrue(parent.allow_billable, "The parent task should be billable as the project linked is billable") self.assertEqual(parent.partner_id, self.project.partner_id, "The partner set on the parent task should the one set on the project linked") self.assertFalse(child.project_id, "The project set on the subtask should be False by default") - self.assertFalse(child.allow_billable, "The subtask should not be billable as it is parent task since no project is set") - self.assertFalse(child.partner_id, "Subtask with no project should not have partner") + self.assertTrue(child.allow_billable, "The subtask should be billable since its parent task's project is billable") + self.assertEqual(child.partner_id, self.project.partner_id, "The partner set on the subtask should the one set on the project linked to the parent") def test_creating_subtask_user_id_on_parent_dont_go_on_child(self): parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)]}) @@ -216,3 +216,33 @@ class TestNestedTaskUpdate(TransactionCase): for child in children: self.assertEqual(child.sale_line_id, self.order_line) self.assertFalse(child.user_ids) + + def test_allow_billable_on_subtasks(self): + parent = self.env['project.task'].create({ + 'name': 'Parent Task', + 'project_id': self.project.id, + 'child_ids': [ + Command.create({ + 'name': 'Subtask with project set', + 'project_id': self.project.id, + }), + Command.create({ + 'name': 'Subtask without any project set', + 'child_ids': [Command.create({'name': 'Subsubtask'})], + }), + ], + }) + subtask_with_project = parent.child_ids.filtered('project_id') + subtask_without_project = parent.child_ids - subtask_with_project + subsubtask = subtask_without_project.child_ids + + self.assertTrue(self.project.allow_billable) + self.assertTrue(parent.allow_billable) + self.assertTrue(subtask_with_project.allow_billable) + self.assertTrue(subtask_without_project.allow_billable) + self.assertTrue(subsubtask.allow_billable) + + project_non_billable = self.env['project.project'].create({'name': 'Non-billable project', 'allow_billable': False}) + subtask_without_project.project_id = project_non_billable + self.assertFalse(subtask_without_project.allow_billable) + self.assertFalse(subsubtask.allow_billable) diff --git a/addons/sale_project/tests/test_sale_project.py b/addons/sale_project/tests/test_sale_project.py index 8eec56e2172..de7eac8bb3d 100644 --- a/addons/sale_project/tests/test_sale_project.py +++ b/addons/sale_project/tests/test_sale_project.py @@ -325,7 +325,7 @@ class TestSaleProject(TestSaleProjectCommon): ] }) sub_B_without = task_B.child_ids.filtered(lambda sub: sub.name == 'Sub B without project') - self.assertEqual(len(sub_B_without.sale_line_id), 0) + self.assertEqual(sub_B_without.sale_line_id, task_B.sale_line_id) # [CASE 4] Without parent --> use sale order line of the project task_D = self.env['project.task'].create({ diff --git a/addons/sale_project/views/project_task_views.xml b/addons/sale_project/views/project_task_views.xml index fdecb7274da..b4aefd19fc2 100644 --- a/addons/sale_project/views/project_task_views.xml +++ b/addons/sale_project/views/project_task_views.xml @@ -90,8 +90,8 @@ - - + + diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 1225120839f..c34056b089d 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -524,10 +524,10 @@ class ProjectTask(models.Model): sale_order_id = fields.Many2one(domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") so_analytic_account_id = fields.Many2one(related='sale_order_id.analytic_account_id', string='Sale Order Analytic Account') - pricing_type = fields.Selection(related="project_id.pricing_type") + pricing_type = fields.Selection(related="project_root_id.pricing_type") is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty') has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True) - timesheet_product_id = fields.Many2one(related="project_id.timesheet_product_id") + timesheet_product_id = fields.Many2one(related="project_root_id.timesheet_product_id") remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so', search='_search_remaining_hours_so', compute_sudo=True) remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")