[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
This commit is contained in:
Audric Onockx (auon)
2023-06-10 12:18:56 +02:00
committed by Xavier Bol (xbo)
parent ca8dc2d9b4
commit 6b3c5370a8
14 changed files with 261 additions and 29 deletions
+8 -8
View File
@@ -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:
+11 -4
View File
@@ -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)
+61 -4
View File
@@ -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})
@@ -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.
</div>
</group>
<field name="timesheet_ids" mode="tree,kanban" attrs="{'invisible': [('analytic_account_active', '=', False)]}" context="{'default_project_id': project_id, 'default_name':''}">
<field name="timesheet_ids" mode="tree,kanban" attrs="{'invisible': [('analytic_account_active', '=', False)]}" context="{'default_project_id': project_root_id, 'default_name':''}">
<tree editable="bottom" string="Timesheet Activities" default_order="date" decoration-muted="readonly_timesheet == True">
<field name="readonly_timesheet" invisible="1"/>
<field name="date" attrs="{'readonly': [('readonly_timesheet', '=', True)]}"/>
+75 -1
View File
@@ -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):
+29
View File
@@ -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)
@@ -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)
@@ -250,6 +250,7 @@
<field name="parent_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="is_private" invisible="1"/>
<field name="project_root_id" invisible="1"/>
<header>
<field name="stage_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" attrs="{'invisible': [('project_id', '=', False), ('stage_id', '=', False)]}"/>
<field name="state" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" invisible="1"/>
@@ -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
+2 -2
View File
@@ -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')
+32 -2
View File
@@ -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)
@@ -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({
@@ -90,8 +90,8 @@
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="sale_order_id" attrs="{'invisible': True}" groups="sales_team.group_sale_salesman"/>
<field name="sale_line_id" groups="!sales_team.group_sale_salesman" string="Sales Order Item" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('project_id', '=', False)]}"/>
<field name="sale_line_id" groups="sales_team.group_sale_salesman" string="Sales Order Item" options='{"no_create": True}' readonly="0" context="{'create': False, 'edit': False, 'delete': False, 'with_price_unit': True}" placeholder="Non-billable" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('project_id', '=', False)]}"/>
<field name="sale_line_id" groups="!sales_team.group_sale_salesman" string="Sales Order Item" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('project_root_id', '=', False)]}"/>
<field name="sale_line_id" groups="sales_team.group_sale_salesman" string="Sales Order Item" options='{"no_create": True}' readonly="0" context="{'create': False, 'edit': False, 'delete': False, 'with_price_unit': True}" placeholder="Non-billable" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('project_root_id', '=', False)]}"/>
<field name="allow_billable" invisible="1"/>
</xpath>
<xpath expr="//field[@name='child_ids']/tree/field[@name='partner_id']" position="after">
+2 -2
View File
@@ -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")