[IMP] project,_*: allow all project features on tasks w/o project

_*: hr_timesheet,sale_project,sale_timesheet
Generally, you don't want subtasks to be displayed at the same level
as their parent tasks. You want to consider the subtasks as part of
their parent's project, but don't want to see them directly in this
project. Rather, you want them to be accessible only via their parent.
(There are exceptions to that and we want to stay flexible.)

First solution that comes to mind is to have `project_id` set to False
for the latter tasks, but then how to get the value of the fields that
are related to the project?

So, second solution would be to have a field `project_root_id`,
which would be the project of the parent, or the grand-parent, etc.
The issue now is that we have to fields "project", and it isn't obvious
when to use one or the other.

The most simple way to answer this need is to keep one field "project",
that will always be set for (non-private) tasks,
and create a boolean field : `display_in_project`.
But we want it to be technical (no checkbox in the view).
So, when the user unsets the project on a subtask, the view will act
as if the project was unset, but in the back-end,
we'll set `display_in_project` to False and set `project_id` back.

The fact that all tasks have a project allows us to know if action x
can be perform on this task t, dependind on t.project_id.allow_x.

task-3367246

closes odoo/odoo#128281

Related: odoo/enterprise#43996
Related: odoo/upgrade#4930
Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
Audric Onockx (auon)
2023-07-20 13:06:46 +02:00
committed by Xavier Bol (xbo)
parent fbd38450b1
commit fb88a7448c
27 changed files with 244 additions and 354 deletions
+7 -7
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_root_id.allow_timesheets', '=', True), ('project_root_id', '=?', project_id)]")
domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True), ('project_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,
@@ -98,9 +98,9 @@ class AccountAnalyticLine(models.Model):
@api.depends('task_id')
def _compute_project_id(self):
for line in self:
if not line.task_id.project_root_id or line.project_id == line.task_id.project_root_id:
if not line.task_id.project_id or line.project_id == line.task_id.project_id:
continue
line.project_id = line.task_id.project_root_id
line.project_id = line.task_id.project_id
@api.depends('project_id')
def _compute_task_id(self):
@@ -112,7 +112,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_root_id:
if self.project_id != self.task_id.project_id:
self.task_id = False
@api.depends('employee_id')
@@ -319,9 +319,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_root_id:
if not task.project_id:
raise ValidationError(_('Timesheets cannot be created on a private task.'))
account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_root_id.analytic_account_id.ids)
account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_id.analytic_account_id.ids)
project_per_id = {}
if project_ids:
@@ -341,7 +341,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_root_id.id
vals['project_id'] = data.project_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:
+8 -23
View File
@@ -26,9 +26,8 @@ class Task(models.Model):
_inherit = "project.task"
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_internal_project', '=', False)]")
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)
allow_timesheets = fields.Boolean("Allow timesheets", related="project_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)
@@ -49,13 +48,19 @@ class Task(models.Model):
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS
@api.constrains('project_id')
def _check_project_root(self):
private_tasks = self.filtered(lambda t: not t.project_id)
if private_tasks and self.env['account.analytic.line'].sudo().search_count([('task_id', 'in', private_tasks.ids)], limit=1):
raise UserError(_("This task cannot be private because there are some timesheets linked to it."))
def _uom_in_days(self):
return self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
def _compute_encode_uom_in_days(self):
self.encode_uom_in_days = self._uom_in_days()
@api.depends('analytic_account_id.active', 'project_root_id.analytic_account_id.active')
@api.depends('analytic_account_id.active', 'project_id.analytic_account_id.active')
def _compute_analytic_account_active(self):
""" Overridden in sale_timesheet """
for task in self:
@@ -161,26 +166,6 @@ class Task(models.Model):
# Is override in sale_timesheet
return self.timesheet_ids
def write(self, values):
# a timesheet must have an analytic account (and a project)
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)
if 'project_id' in values:
project = self.env['project.project'].browse(values.get('project_id'))
if project.allow_timesheets:
# We write on all non yet invoiced timesheet the new project_id (if project allow timesheet)
self._get_timesheet().write({'project_id': values.get('project_id')})
return res
@api.depends('allow_timesheets', 'planned_hours', 'encode_uom_in_days', 'remaining_hours')
@api.depends_context('hr_timesheet_display_remaining_hours')
def _compute_display_name(self):
+18 -23
View File
@@ -291,17 +291,21 @@ class TestTimesheet(TestCommonTimesheet):
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)])
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)])
self.assertEqual(timesheet_count1, 2, "There are still timesheets linked to Project1")
self.assertEqual(timesheet_count2, 1, "1 timesheets should be linked to Project2")
self.assertEqual(timesheet_count1, 3, "3 timesheets should be linked to Project1")
self.assertEqual(timesheet_count2, 0, "No timesheets should be linked to Project2")
self.assertEqual(len(self.task1.timesheet_ids), 1, "The timesheet still should be linked to task1")
self.assertEqual(len(task_child.timesheet_ids), 1, "The timesheet still should be linked to task_child")
self.assertEqual(len(task_grandchild.timesheet_ids), 1, "The timesheet still should be linked to task_grandchild")
# it is forbidden to set a task with timesheet without project
# It is forbidden to unset the project of a task with timesheet...
with self.assertRaises(UserError):
self.task1.write({
'project_id': False
})
# ...except if one of its ascendant has one.
task_child.write({
'project_id': False
})
def test_recompute_amount_for_multiple_timesheets(self):
""" Check that amount is recomputed correctly when setting unit_amount for multiple timesheets at once. """
@@ -363,7 +367,6 @@ class TestTimesheet(TestCommonTimesheet):
def test_task_with_timesheet_project_change(self):
'''This test checks that no error is raised when moving a task that contains timesheet to another project.
This move implying writing on the account.analytic.line.
'''
project_manager = self.env['res.users'].create({
@@ -403,7 +406,7 @@ class TestTimesheet(TestCommonTimesheet):
'project_id': second_project.id
})
self.assertEqual(timesheet.project_id, second_project, 'The project_id of timesheet should be second_project')
self.assertEqual(timesheet.project_id, project, 'The project_id of timesheet shouldn\'t have changed')
def test_create_timesheet_employee_not_in_company(self):
''' ts.employee_id only if the user has an employee in the company or one employee for all companies.
@@ -484,40 +487,35 @@ class TestTimesheet(TestCommonTimesheet):
3) Enter the 8 hour timesheet in the child task
4) Check subtask Effective hours in parent task
"""
subtask_with_project, subtask_without_project = self.env['project.task'].create([
subtask_1, subtask_2 = self.env['project.task'].create([
{
'name': 'Subtask with project set',
'name': 'Subtask 1',
'project_id': self.project_customer.id,
},
{
'name': 'Suubtask without project set',
'child_ids': [Command.create({'name': 'subsubtask without project set'})],
'name': 'Subtask 2',
'project_id': self.project_customer.id,
'child_ids': [Command.create({'name': 'Subsubtask'})],
},
])
subsubtask = subtask_without_project.child_ids
self.task1.child_ids = subtask_with_project + subtask_without_project
subsubtask = subtask_2.child_ids
self.task1.child_ids = subtask_1 + subtask_2
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.')
self.assertEqual(subtask_1.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': subtask_with_project.id,
'task_id': subtask_1.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.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,
'task_id': subtask_2.id,
'unit_amount': 1.0,
'employee_id': self.empl_employee2.id,
},
@@ -658,9 +656,6 @@ class TestTimesheet(TestCommonTimesheet):
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):
@@ -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_root_id, 'default_name':''}">
<field name="timesheet_ids" mode="tree,kanban" attrs="{'invisible': [('analytic_account_active', '=', False)]}" context="{'default_project_id': project_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)]}"/>
+1 -1
View File
@@ -196,7 +196,7 @@
'project/static/src/components/project_task_name_with_subtask_count_char_field/*',
'project/static/src/components/project_task_state_selection/*',
'project/static/src/components/project_private_task_many2one_field/*',
'project/static/src/components/project_many2one_field/*',
'partner_autocomplete/static/src/js/partner_autocomplete_core.js',
'partner_autocomplete/static/src/js/partner_autocomplete_many2one.js',
'partner_autocomplete/static/src/xml/partner_autocomplete.xml',
+76 -112
View File
@@ -19,9 +19,9 @@ PROJECT_TASK_READABLE_FIELDS = {
'active',
'priority',
'project_id',
'display_in_project',
'color',
'subtask_count',
'is_private',
'email_from',
'create_date',
'write_date',
@@ -34,7 +34,6 @@ PROJECT_TASK_READABLE_FIELDS = {
'allow_milestones',
'milestone_id',
'has_late_and_unreached_milestone',
'company_id',
'date_assign',
'dependent_ids',
'message_is_follower',
@@ -155,7 +154,7 @@ class Task(models.Model):
"Based on this information you can identify tasks that are stalling and get statistics on the time it usually takes to move tasks from one stage/state to another.")
project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id)]", index=True, tracking=True, change_default=True)
project_root_id = fields.Many2one('project.project', compute='_compute_project_root_id', search='_search_project_root_id', recursive=True)
display_in_project = fields.Boolean(default=True, readonly=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',
@@ -203,7 +202,6 @@ class Task(models.Model):
working_days_close = fields.Float(compute='_compute_elapsed', string='Working Days to Close', store=True, group_operator="avg")
# 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'])])
is_private = fields.Boolean(compute='_compute_is_private', search='_search_is_private')
allow_milestones = fields.Boolean(related='project_id.allow_milestones')
milestone_id = fields.Many2one(
'project.milestone',
@@ -268,7 +266,8 @@ class Task(models.Model):
)
_sql_constraints = [
('recurring_task_has_no_parent', 'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))', "A subtask cannot be recurrent.")
('recurring_task_has_no_parent', 'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))', "A subtask cannot be recurrent."),
('private_task_has_no_parent', 'CHECK (NOT (project_id IS NULL AND parent_id IS NOT NULL))', "A private task cannot have a parent."),
]
@api.constrains('company_id', 'partner_id')
@@ -291,96 +290,6 @@ class Task(models.Model):
for task in self:
task.analytic_account_id = task.project_id.analytic_account_id
@api.depends('project_id', 'parent_id')
def _compute_is_private(self):
# Modify accordingly, this field is used to display the lock on the task's kanban card
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)
if operator not in ['=', '!=']:
raise NotImplementedError(_('Operation should be = or != (not %s)'), value)
domain = expression.normalize_domain([('project_id', '=', False), ('parent_id', '=', False)])
if (operator == '=') != value:
domain.insert(0, expression.NOT_OPERATOR)
domain = expression.distribute_not(domain)
return domain
@api.depends('depend_on_ids.state', 'project_id.allow_task_dependencies')
def _compute_state(self):
for task in self:
@@ -623,9 +532,10 @@ class Task(models.Model):
@api.depends('project_id')
def _compute_stage_id(self):
for task in self:
if task.project_id:
if task.project_id not in task.stage_id.project_ids:
task.stage_id = task.stage_find(task.project_id.id, [('fold', '=', False)])
project = task.project_id or task.parent_id.project_id
if project:
if project not in task.stage_id.project_ids:
task.stage_id = task.stage_find(project.id, [('fold', '=', False)])
else:
task.stage_id = False
@@ -948,6 +858,28 @@ class Task(models.Model):
self.check_access_rights('create')
default_stage = dict()
for vals in vals_list:
project_id = vals.get('project_id')
if vals.get('user_ids'):
vals['date_assign'] = fields.Datetime.now()
if not (vals.get('parent_id') or project_id or self._context.get('default_project_id')):
user_ids = self._fields['user_ids'].convert_to_cache(vals.get('user_ids', []), self)
if self.env.user.id not in list(user_ids) + [SUPERUSER_ID]:
vals['user_ids'] = [Command.set(list(user_ids) + [self.env.user.id])]
if project_id:
# set the project => "I want to display the task in the project"
# => => set `display_in_project` to True
vals['display_in_project'] = vals.get('display_in_project', True)
elif vals.get('parent_id'):
# unset the project => 2 cases:
# 1) the task has no parent => "I want it to be private" => nothing to do
# 2) the task has a parent => "I don't want to display the task in the project"
# => set `project_id` to the one of its parent and `display_in_project` to False
project_id = self.browse(vals['parent_id']).project_id.id
vals.update({
'project_id': project_id,
'display_in_project': False,
})
if default_personal_stage and 'personal_stage_type_id' not in vals:
vals['personal_stage_type_id'] = default_personal_stage[0]
if not vals.get('name') and vals.get('display_name'):
@@ -955,7 +887,6 @@ class Task(models.Model):
if is_portal_user:
self._ensure_fields_are_accessible(vals.keys(), operation='write', check_group_user=False)
project_id = vals.get('project_id') or self.env.context.get('default_project_id')
if project_id and not "company_id" in vals:
vals["company_id"] = self.env["project.project"].browse(
project_id
@@ -973,12 +904,6 @@ class Task(models.Model):
).default_get(['stage_id']).get('stage_id')
vals["stage_id"] = default_stage[project_id]
# user_ids change: update date_assign
if vals.get('user_ids'):
vals['date_assign'] = fields.Datetime.now()
if not project_id:
user_ids = self._fields['user_ids'].convert_to_cache(vals.get('user_ids', []), self)
if self.env.user.id not in list(user_ids) + [SUPERUSER_ID]:
vals['user_ids'] = [Command.set(list(user_ids) + [self.env.user.id])]
# Stage change: Update date_end if folded stage and date_last_stage_update
if vals.get('stage_id'):
vals.update(self.update_date_end(vals['stage_id']))
@@ -1032,10 +957,49 @@ class Task(models.Model):
self.check_access_rule('write')
portal_can_write = True
now = fields.Datetime.now()
if 'parent_id' in vals and vals['parent_id'] in self.ids:
raise UserError(_("Sorry. You can't set a task as its parent task."))
if 'project_id' in vals:
project_id = vals['project_id']
if project_id:
# set the project => "I want to display the task in the project"
# => set `display_in_project` to True
if 'display_in_project' not in vals:
vals['display_in_project'] = True
no_display_subtasks = self.child_ids.filtered(lambda t: not t.display_in_project)
if no_display_subtasks:
no_display_subtasks.write({'project_id': project_id})
else:
# unset the project => 2 cases:
# 1) the task has no parent => "I want it to be private" => nothing to do
# 2) the task has a parent => "I don't want to display the task in the project"
# => set `project_id` back and `display_in_project` to False
if 'parent_id' in vals:
if vals['parent_id']:
vals.update({
'project_id': self.browse(vals['parent_id']).project_id.id,
'display_in_project': False,
})
else:
task_ids_per_parent_project_id = defaultdict(list)
for task in self:
task_ids_per_parent_project_id[task.parent_id.project_id.id].append(task.id)
self = self.browse(task_ids_per_parent_project_id.pop(False, False))
for parent_project_id, task_ids in task_ids_per_parent_project_id.items():
self.browse(task_ids).write({
**vals,
'project_id': parent_project_id,
'display_in_project': False,
})
if 'parent_id' in vals:
parent_id = vals['parent_id']
if parent_id in self.ids:
raise UserError(_("Sorry. You can't set a task as its parent task."))
elif not parent_id:
# unset the parent => "I want to display the task back in the project"
# => set `display_in_project` to True
vals['display_in_project'] = True
# stage change: update date_last_stage_update
now = fields.Datetime.now()
if 'stage_id' in vals:
if not 'project_id' in vals and self.filtered(lambda t: not t.project_id):
raise UserError(_('You can only set a personal stage on a private task.'))
@@ -1127,14 +1091,14 @@ class Task(models.Model):
# Subtasks
# ---------------------------------------------------
@api.depends('parent_id.partner_id', 'project_id', 'is_private')
@api.depends('parent_id.partner_id', 'project_id')
def _compute_partner_id(self):
""" Compute the partner_id when the tasks have no partner_id.
Use the project partner_id if any, or else the parent task partner_id.
"""
for task in self:
if task.partner_id and task.is_private:
if task.partner_id and not (task.project_id or task.parent_id):
task.partner_id = False
continue
if not task.partner_id:
@@ -1143,7 +1107,7 @@ class Task(models.Model):
@api.depends('project_id')
def _compute_milestone_id(self):
for task in self:
if task.project_id != task.milestone_id.project_id:
if (task.project_id or task.parent_id.project_id) != task.milestone_id.project_id:
task.milestone_id = False
def _compute_has_late_and_unreached_milestone(self):
@@ -1614,7 +1578,7 @@ class Task(models.Model):
# ---------------------------------------------------
def _get_task_analytic_account_id(self):
self.ensure_one()
return self.analytic_account_id or self.project_root_id.analytic_account_id
return self.analytic_account_id or self.project_id.analytic_account_id
@api.model
def get_unusual_days(self, date_from, date_to=None):
+2 -1
View File
@@ -117,7 +117,8 @@ class ProjectTask(models.Model):
rand = populate.Random('project.task+children_generator')
task_ids_per_company = collections.defaultdict(set)
for task in tasks:
task_ids_per_company[task.company_id].add(task.id)
if task.project_id:
task_ids_per_company[task.company_id].add(task.id)
for task_ids in task_ids_per_company.values():
parent_ids = set()
-1
View File
@@ -91,7 +91,6 @@ class ReportProjectTaskUser(models.Model):
t.working_hours_open,
t.working_hours_close,
(extract('epoch' from (t.date_deadline-(now() at time zone 'UTC'))))/(3600*24) as delay_endings_days,
CASE WHEN t.project_id IS NOT NULL OR t.parent_id IS NOT NULL THEN false ELSE true END as is_private,
COUNT(td.task_id) as dependent_ids_count
"""
+1 -1
View File
@@ -100,7 +100,7 @@
<field name="name">Project/Task: project manager: see all tasks linked to a project or its own tasks</field>
<field name="model_id" ref="model_project_task"/>
<field name="domain_force">[
'|', ('is_private', '=', False),
'|', ('project_id', '!=', False),
('user_ids', 'in', user.id),
]</field>
<field name="groups" eval="[(4,ref('project.group_project_manager'))]"/>
@@ -0,0 +1,40 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { Many2OneField, many2OneField } from '@web/views/fields/many2one/many2one_field';
export class ProjectMany2OneField extends Many2OneField {
get Many2XAutocompleteProps() {
const props = super.Many2XAutocompleteProps;
const { project_id, parent_id } = this.props.record.data;
if (!project_id && !parent_id) {
props.placeholder = this.env._t("Private");
}
return props;
}
get displayName() {
const { project_id, display_in_project } = this.props.record.data;
return project_id && !display_in_project ? "" : super.displayName;
}
updateRecord(value) {
const { project_id, display_in_project } = this.props.record.data;
if (!display_in_project && value && value[0] === project_id[0]) {
this.props.record.update({ "display_in_project": true });
}
super.updateRecord(value);
}
}
ProjectMany2OneField.template = 'project.ProjectMany2OneField';
export const projectMany2OneField = {
...many2OneField,
component: ProjectMany2OneField,
fieldDependencies: [
...(many2OneField.fieldDependencies || []),
{ name: "display_in_project", type: "boolean" },
],
};
registry.category("fields").add("project", projectMany2OneField);
@@ -0,0 +1,4 @@
.private_placeholder input::placeholder {
color: $red;
font-style: italic;
}
@@ -1,21 +1,21 @@
<templates>
<t t-name="project.ProjectPrivateTaskMany2OneField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
<t t-name="project.ProjectMany2OneField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-if='!props.canOpen']/span" position="attributes">
<attribute name="t-if">props.record.data[props.name]</attribute>
</xpath>
<xpath expr="//t[@t-if='!props.canOpen']/span" position="after">
<span t-elif="props.record.data.is_private" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
<span t-elif="!props.record.data.parent_id &amp;&amp; !props.record.data.project_id" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</xpath>
<xpath expr="//t[@t-else='']/a" position="attributes">
<attribute name="t-if">displayName</attribute>
</xpath>
<xpath expr="//t[@t-else='']/a" position="after">
<span t-elif="props.record.data.is_private" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
<span t-elif="!props.record.data.parent_id &amp;&amp; !props.record.data.project_id" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</xpath>
<xpath expr="//div[hasclass('o_field_many2one_selection')]" position="attributes">
<attribute name="t-att-class">{
project_private_task_many2one_field: props.record.data.is_private,
private_placeholder: !props.record.data.parent_id &amp;&amp; !props.record.data.project_id,
}</attribute>
</xpath>
</t>
@@ -1,26 +0,0 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { Many2OneField, many2OneField } from '@web/views/fields/many2one/many2one_field';
export class ProjectPrivateTaskMany2OneField extends Many2OneField {
get Many2XAutocompleteProps() {
const props = super.Many2XAutocompleteProps;
if (this.props.record.data.is_private) {
props.placeholder = this.env._t("Private");
}
return props;
}
}
ProjectPrivateTaskMany2OneField.template = 'project.ProjectPrivateTaskMany2OneField';
export const projectPrivateTaskMany2OneField = {
...many2OneField,
component: ProjectPrivateTaskMany2OneField,
fieldDependencies: [
...(many2OneField.fieldDependencies || []),
{ name: "is_private", type: "boolean" },
],
};
registry.category("fields").add("project_private_task", projectPrivateTaskMany2OneField);
@@ -1,4 +0,0 @@
.project_private_task_many2one_field input::placeholder {
color: $red;
font-style: italic;
}
@@ -68,7 +68,7 @@ export class SubtaskKanbanList extends Component {
field: getFieldFromRegistry(this.fields.user_ids.type, "many2many_avatar_user", "kanban"),
},
project_id: {
field: getFieldFromRegistry(this.fields.project_id.type, "project_private_task", "kanban")
field: getFieldFromRegistry(this.fields.project_id.type, "project", "kanban")
},
};
}
+4 -5
View File
@@ -432,8 +432,6 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
task = task_form.save()
self.assertEqual(task.company_id, self.project_company_b.company_id, "The company of the subtask should be the one from its project, and not from its parent.")
# set parent on existing orphan task; the onchange will set the correct company and subtask project
self.task_2.write({'project_id': False})
# For `parent_id` to be visible in the view, you need
# 1. The debug mode
# <field name="parent_id" groups="base.group_no_one"/>
@@ -446,10 +444,11 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
with Form(self.task_2) as task_form:
task_form.name = 'Test Task 2 becomes child of Task 1 (other company)'
task_form.parent_id = self.task_1
task_form.project_id = self.env['project.project']
task = task_form.save()
self.assertFalse(task.project_id, "The subtask should not have any project set")
self.assertEqual(task.company_id, self.task_1.company_id, "The company of the subtask should be the one from its parent when no project is set.")
self.assertEqual(task.project_id, task.parent_id.project_id, "The subtask should have the same project as its parents")
self.assertEqual(task.company_id, task.parent_id.company_id, "The company of the subtask should be the one from its parent when no project is set.")
def test_cross_subtask_project(self):
@@ -471,7 +470,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
task = task_form.save()
self.assertFalse(task.project_id, "No project should be set on the subtask by default")
self.assertEqual(task.project_id, task.parent_id.project_id, "The subtask should have the same project as its parents")
self.assertEqual(task.company_id, task.parent_id.company_id, "The company of the subtask should be the one from its parent.")
self.assertEqual(self.task_1.child_ids.ids, [task.id])
-32
View File
@@ -233,38 +233,6 @@ class TestProjectBase(TestProjectCommon):
partner.company_id = company_1
self.assertEqual(partner.company_id, company_1, "The company of the partner should have been updated.")
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
other_projects.allow_milestones = True
# Restrict all searches to the three test projects to avoid interacting with other data
base_domain = [('project_root_id', 'in', [project.id] + other_projects.ids)]
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(expression.AND([base_domain, [('project_root_id', '=', project.id)]])), tasks)
self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', 'in', project.ids)]])), tasks)
self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id.allow_milestones', '=', False)]])), tasks)
self.assertEqual(ProjectTask.search(expression.AND([base_domain, ['!', ('project_root_id.allow_milestones', '=', True)]])), tasks)
self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', '=?', project.id)]])), tasks)
self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', 'not in', other_projects.ids), ('id', 'in', tasks.ids)]])), tasks)
self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', '!=', self.project_pigs.id), ('id', 'in', tasks.ids)]])), tasks)
def test_add_customer_rating_project(self):
""" Tests that the rating_ids field contains a rating once created
"""
+8 -41
View File
@@ -75,9 +75,9 @@ class TestProjectSubtasks(TestProjectCommon):
- Should not change parent project
- Project should be correct
3) Reset the project to False
- Project should be correct
- Project should be inheritted from parent
4) Change parent task project
- Project should stay false
- Project should be inheritted from parent
5) Set project on subtask and change parent task project
- Project should be the one set by the user
6) Remove parent task:
@@ -91,7 +91,7 @@ class TestProjectSubtasks(TestProjectCommon):
with task_form.child_ids.new() as child_task_form:
child_task_form.name = 'Test Subtask 1'
self.assertFalse(self.task_1.child_ids.project_id, "The project should be False by default.")
self.assertEqual(self.task_1.child_ids.project_id, self.task_1.project_id, "The project should be inheritted from parent.")
# 2)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
@@ -106,14 +106,14 @@ class TestProjectSubtasks(TestProjectCommon):
with task_form.child_ids.edit(0) as child_task_form:
child_task_form.project_id = self.env['project.project']
self.assertFalse(self.task_1.child_ids.project_id, "The project of the subtask should be False")
self.assertEqual(self.task_1.child_ids.project_id, self.task_1.project_id, "The project of the subtask should be inheritted from parent")
# 4)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.project_id = self.project_goats
self.assertEqual(self.task_1.project_id, self.project_goats, "Parent project should change.")
self.assertFalse(self.task_1.child_ids.project_id, "The project of the subtask should stay False")
self.assertEqual(self.task_1.child_ids.project_id, self.task_1.project_id, "The project of the subtask should stay False")
# 5)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
@@ -160,7 +160,7 @@ class TestProjectSubtasks(TestProjectCommon):
with task_form.child_ids.new() as child_task_form:
child_task_form.name = 'Test Subtask 1'
self.assertFalse(self.task_1.child_ids.stage_id, "No stage should be set on the subtask since no project is set by default.")
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "Stage should be set on the subtask since it inheritted the project of its parent.")
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.edit(0) as subtask_form:
subtask_form.project_id = task_form.project_id
@@ -249,42 +249,9 @@ class TestProjectSubtasks(TestProjectCommon):
with Form(child_subtask.with_context(tracking_disable=True)) as subtask_form:
with subtask_form.child_ids.new() as child_subtask_form:
child_subtask_form.name = 'Test Subtask 2'
self.assertFalse(child_subtask_form.project_id)
self.assertEqual(child_subtask_form.project_id, subtask_form.project_id)
self.assertFalse(child_subtask_form.display_in_project)
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)
@@ -182,7 +182,7 @@
<field name="description" type="html" options="{'collaborative': true}"/>
</page>
<page name="sub_tasks_page" string="Sub-tasks">
<field name="child_ids" context="{'default_project_id': project_id, 'default_parent_id': id, 'default_partner_id': partner_id, 'form_view_ref' : 'project.project_sharing_project_task_view_form'}">
<field name="child_ids" context="{'default_parent_id': id, 'default_partner_id': partner_id, 'form_view_ref' : 'project.project_sharing_project_task_view_form'}">
<tree editable="bottom">
<field name="project_id" invisible="1"/>
<field name="state" invisible="1"/>
@@ -205,7 +205,7 @@
<field name="stage_id" optional="show"/>
<button name="action_open_task" type="object" title="View Task" string="View Task" class="btn btn-link float-end"
context="{'form_view_ref': 'project.project_sharing_project_task_view_form', 'search_view_ref': 'project.project_sharing_project_task_view_search'}"
attrs="{'invisible': &quot;['|', ('project_id', '=', False), ('project_id', '!=', active_id)]&quot;}"/>
attrs="{'invisible': &quot;['|', ('project_id', '!=', False), ('project_id', '!=', active_id)]&quot;}"/>
</tree>
</field>
</page>
@@ -232,7 +232,7 @@
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form</field>
<field name="search_view_id" ref="project.project_sharing_project_task_view_search"/>
<field name="domain">[('project_id', '=', active_id)]</field>
<field name="domain">[('project_id', '=', active_id), ('display_in_project', '=', True)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No tasks found. Let's create one!
+23 -26
View File
@@ -145,7 +145,7 @@
<field name="name">Tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form,calendar,pivot,graph,activity</field>
<field name="domain">[('project_id', '=', active_id)]</field>
<field name="domain">[('project_id', '=', active_id), ('display_in_project', '=', True)]</field>
<field name="context">{
'default_project_id': active_id,
'show_project_update': True,
@@ -250,12 +250,11 @@
<field name="allow_milestones" invisible="1" />
<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"/>
<field name="project_id" invisible="1"/>
<header>
<field name="stage_id" widget="statusbar_duration" 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"/>
<field name="personal_stage_type_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" attrs="{'invisible': [('is_private', '=', False)]}" domain="[('user_id', '=', uid)]" string="Personal Stage"/>
<field name="personal_stage_type_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" attrs="{'invisible': [('project_id', '!=', False)]}" domain="[('user_id', '=', uid)]" string="Personal Stage"/>
</header>
<sheet string="Task">
<div class="oe_button_box" name="button_box">
@@ -314,7 +313,7 @@
<group>
<field name="project_id"
domain="[('active', '=', True), '|', ('company_id', '=', False), ('company_id', '=?', company_id)]"
widget="project_private_task"
widget="project"
/>
<field name="milestone_id"
placeholder="e.g. Product Launch"
@@ -361,21 +360,23 @@
<page name="description_page" string="Description">
<field name="description" type="html" options="{'collaborative': true, 'resizable': false}" placeholder="Add details about this task..."/>
</page>
<page name="sub_tasks_page" string="Sub-tasks" attrs="{'invisible': [('is_private', '=', True)]}">
<page name="sub_tasks_page" string="Sub-tasks" attrs="{'invisible': [('project_id', '=', False)]}">
<field name="child_ids"
context="{'default_user_ids': user_ids, 'default_parent_id': id,
context="{'default_project_id': project_id, 'default_display_in_project': False, 'default_user_ids': user_ids, 'default_parent_id': id,
'default_partner_id': partner_id, 'default_milestone_id': allow_milestones and milestone_id}"
widget="subtasks_one2many">
<tree editable="bottom" decoration-muted="state in ['1_done','1_canceled']">
<field name="allow_milestones" invisible="1"/>
<field name="display_in_project" invisible="1" force_save="1"/>
<field name="sequence" widget="handle"/>
<field name="id" optional="hide"/>
<field name="parent_id" invisible="1"/>
<field name="priority" widget="priority" nolabel="1" options="{'autosave': False}" width="40px"/>
<field name="state" widget="project_task_state_selection" nolabel="1" options="{'hide_label': 1, 'autosave': False}" width="40px"/>
<field name="name" widget="name_with_subtask_count"/>
<field name="subtask_count" invisible="1"/>
<field name="closed_subtask_count" invisible="1"/>
<field name="project_id" string="Project" optional="hide" options="{'no_open': 1}"/>
<field name="project_id" string="Project" optional="hide" options="{'no_open': 1}" widget="project"/>
<field name="milestone_id"
optional="hide"
context="{'default_project_id': project_id}"
@@ -489,7 +490,7 @@
<group>
<field name="display_name" string= "Task Title" placeholder="e.g. Send Invitations" required="1"/>
<field name="project_id"
widget="project_private_task"
widget="project"
invisible="context.get('default_project_id', False)"
placeholder="Private"
class="o_project_task_project_field"
@@ -531,7 +532,6 @@
<field name="activity_state"/>
<field name="rating_count"/>
<field name="rating_avg"/>
<field name="is_private"/>
<field name="rating_active"/>
<field name="has_late_and_unreached_milestone" />
<field name="allow_milestones" />
@@ -597,8 +597,8 @@
<a t-if="!record.project_id.raw_value" class="text-muted" style="font-size: 17px; padding-top: 1.5px; margin-left: 1.5px">
<i title="Private Task" class="fa fa-lock"/>
</a>
<t t-if="!record.is_private.raw_value and record.subtask_count.raw_value">
<a t-if="!record.is_private.raw_value and record.subtask_count.raw_value and record.subtask_count.value &gt; record.closed_subtask_count.value"
<t t-if="record.project_id.raw_value and record.subtask_count.raw_value">
<a t-if="record.project_id.raw_value and record.subtask_count.raw_value and record.subtask_count.value &gt; record.closed_subtask_count.value"
t-attf-title="{{ record.closed_subtask_count.value }} sub-tasks closed out of {{ record.subtask_count.value }}"
class="subtask_list_button text-muted">
<span class="fa fa-check-square-o me-1"/>
@@ -617,7 +617,7 @@
<field name="state" widget="project_task_state_selection" options="{'hide_label': 1, 'is_toggle_mode': false}"/>
</div>
</div>
<t t-if="!record.is_private.raw_value and record.subtask_count.raw_value and record.subtask_count.raw_value &gt; record.closed_subtask_count.raw_value">
<t t-if="record.project_id.raw_value and record.subtask_count.raw_value and record.subtask_count.raw_value &gt; record.closed_subtask_count.raw_value">
<div class="kanban_bottom_subtasks_section"/>
</t>
</div>
@@ -636,7 +636,6 @@
<field name="arch" type="xml">
<tree string="Tasks" sample="1" default_order="priority desc, sequence, state, date_deadline asc, id desc">
<field name="company_id" invisible="1"/>
<field name="is_private" invisible="1" />
<field name="sequence" invisible="1" readonly="1"/>
<field name="allow_milestones" invisible="1"/>
<field name="subtask_count" invisible="1"/>
@@ -645,7 +644,7 @@
<field name="priority" widget="priority" nolabel="1"/>
<field name="state" widget="project_task_state_selection" nolabel="1" options="{'hide_label': 1, 'is_toggle_mode': false}"/>
<field name="name" string="Title" widget="name_with_subtask_count"/>
<field name="project_id" widget="project_private_task" optional="show" readonly="1" options="{'no_open': 1}" invisible="context.get('default_project_id')"/>
<field name="project_id" widget="project" optional="show" readonly="1" options="{'no_open': 1}" invisible="context.get('default_project_id')"/>
<field name="milestone_id" attrs="{'invisible': [('allow_milestones', '=', False)]}" context="{'default_project_id': project_id}" groups="project.group_project_milestone" optional="hide"/>
<field name="partner_id" optional="hide" widget="res_partner_many2one" attrs="{'invisible': [('project_id', '=', False)]}"/>
<field name="user_ids" optional="show" widget="many2many_avatar_user" domain="[('share', '=', False), ('active', '=', True)]"/>
@@ -713,16 +712,15 @@
js_class="project_calendar"
scales="month,year">
<field name="allow_milestones" invisible="1" />
<field name="is_private" invisible="1"/>
<field name="project_id" widget="project_private_task" invisible="context.get('default_project_id', False)"/>
<field name="project_id" widget="project" invisible="context.get('default_project_id', False)"/>
<field name="milestone_id" attrs="{'invisible': ['|', ('allow_milestones', '=', False), ('milestone_id', '=', False)]}"/>
<field name="user_ids" widget="many2many_avatar_user" attrs="{'invisible': [('user_ids', '=', [])]}"/>
<field name="partner_id" attrs="{'invisible': [('partner_id', '=', False)]}"/>
<field name="priority" widget="priority"/>
<field name="tag_ids" widget="many2many_tags" attrs="{'invisible': [('tag_ids', '=', [])]}"/>
<field name="stage_id" attrs="{'invisible': ['|', ('is_private', '=', True), ('stage_id', '=', False)]}"/>
<field name="stage_id" attrs="{'invisible': ['|', ('project_id', '=', False), ('stage_id', '=', False)]}"/>
<field name="state" widget="project_task_state_selection" readonly="1"/>
<field name="personal_stage_id" string="Personal Stage" attrs="{'invisible': ['|', ('is_private', '=', False), ('personal_stage_id', '=', False)]}"/>
<field name="personal_stage_id" string="Personal Stage" attrs="{'invisible': ['|', ('project_id', '!=', False), ('personal_stage_id', '=', False)]}"/>
</calendar>
</field>
</record>
@@ -764,7 +762,6 @@
<activity string="Project Tasks" js_class="project_activity">
<field name="user_ids"/>
<field name="project_id"/>
<field name="is_private" />
<templates>
<div class="w-100" t-name="activity-box" style="display: inline-grid; grid-template-columns: auto max-content;">
<div>
@@ -773,7 +770,7 @@
</span>
<span t-att-title="record.project_id.value" invisible="context.get('default_project_id', False)">
<field t-if="record.project_id.value" name="project_id" muted="1" class="w-100 text-truncate"/>
<span t-elif="record.is_private.value" class="fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
<span t-else="" class="fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</span>
</div>
<field name="user_ids" widget="many2many_avatar_user"/>
@@ -788,7 +785,7 @@
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form,calendar,pivot,graph,activity</field>
<field name="context">{'search_default_my_tasks': 1}</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="domain">[('project_id', '!=', False), ('display_in_project', '=', True)]</field>
<field name="search_view_id" ref="view_task_search_form"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -936,7 +933,7 @@
<field name="name">All Tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">tree,kanban,form,calendar,pivot,graph,activity</field>
<field name="domain">['|', ('is_private', '=', True), ('project_id', '!=', False)]</field>
<field name="domain">[('display_in_project', '=', True)]</field>
<field name="context">{'search_default_open_tasks': 1, 'default_user_ids': [(4, uid)]}</field>
<field name="search_view_id" ref="view_task_search_form"/>
<field name="help" type="html">
@@ -991,7 +988,7 @@
<field name="name">Overpassed Tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">tree,form,calendar,graph,kanban</field>
<field name="domain">[('state', 'not in', ['1_done', '1_canceled']), ('date_deadline','&lt;',time.strftime('%Y-%m-%d')), ('project_id', '!=', False)]</field>
<field name="domain">[('state', 'not in', ['1_done', '1_canceled']), ('date_deadline','&lt;',time.strftime('%Y-%m-%d')), ('project_id', '!=', False), ('display_in_project', '=', True)]</field>
<field name="filter" eval="True"/>
<field name="search_view_id" ref="view_task_search_form"/>
</record>
@@ -1001,7 +998,7 @@
<field name="res_model">project.task</field>
<field name="name">Project's tasks</field>
<field name="view_mode">tree,form,calendar,graph,kanban</field>
<field name="domain">[('project_id', '=', active_id)]</field>
<field name="domain">[('project_id', '=', active_id), ('display_in_project', '=', True)]</field>
<field name="context">{'project_id':active_id}</field>
</record>
@@ -1029,7 +1026,7 @@
<field name="res_model">project.task</field>
<field name="view_mode">tree,form,calendar,graph</field>
<field name="context">{'search_default_user_ids': [active_id], 'default_user_ids': [(6, 0, [active_id])]}</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="domain">[('project_id', '!=', False), ('display_in_project', '=', True)]</field>
<field name="binding_model_id" ref="base.model_res_users"/>
<field name="binding_view_types">form</field>
</record>
@@ -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_root_id.allow_timesheets', '=', True),"
"('project_root_id', '=?', project_id), ('is_timeoff_task', '=', False)]")
task_id = fields.Many2one(domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True),"
"('project_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
+4 -4
View File
@@ -757,9 +757,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_root_id.sale_order_id')
project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_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_root_id.allow_billable")
allow_billable = fields.Boolean(related="project_id.allow_billable")
# Project sharing fields
display_sale_order_button = fields.Boolean(string='Display Sales Order', compute='_compute_display_sale_order_button')
@@ -787,14 +787,14 @@ class ProjectTask(models.Model):
@api.depends('allow_billable')
def _compute_partner_id(self):
billable_task = self.filtered('allow_billable')
billable_task = self.filtered(lambda t: t.allow_billable or (not self._origin and t.parent_id.allow_billable))
(self - billable_task).partner_id = False
super(ProjectTask, billable_task)._compute_partner_id()
@api.depends('partner_id.commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'milestone_id.sale_line_id', 'allow_billable')
def _compute_sale_line(self):
for task in self:
if not task.allow_billable:
if not (task.allow_billable or task.parent_id.allow_billable):
task.sale_line_id = False
continue
if not task.sale_line_id:
+25 -25
View File
@@ -42,16 +42,16 @@ class TestNestedTaskUpdate(TransactionCase):
def test_default_values_creating_subtask(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [Command.link(self.user.id)], 'project_id': self.project.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'project_id': self.project.id})
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.assertEqual(child.project_id, parent.project_id, "The project set on the subtask be inheritted from parent")
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)]})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'user_ids': False})
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)], 'project_id': self.project.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'user_ids': False, 'project_id': self.project.id})
self.assertFalse(child.user_ids)
def test_creating_subtask_partner_id_on_parent_goes_on_child(self):
@@ -99,8 +99,8 @@ class TestNestedTaskUpdate(TransactionCase):
#----------------------------------------
def test_write_user_id_on_parent_dont_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': False})
child = self.env['project.task'].create({'name': 'child', 'user_ids': False, 'parent_id': parent.id})
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': False, 'project_id': self.project.id})
child = self.env['project.task'].create({'name': 'child', 'user_ids': False, 'parent_id': parent.id, 'project_id': self.project.id})
self.assertFalse(child.user_ids)
parent.write({'user_ids': [(4, self.user.id)]})
self.assertFalse(child.user_ids)
@@ -108,8 +108,13 @@ class TestNestedTaskUpdate(TransactionCase):
self.assertFalse(child.user_ids)
def test_write_partner_id_on_parent_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': False})
child = self.env['project.task'].create({'name': 'child', 'partner_id': False, 'parent_id': parent.id})
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': False, 'project_id': self.project.id})
child = self.env['project.task'].create({
'name': 'child',
'partner_id': False,
'parent_id': parent.id,
'project_id': self.env['project.project'].create({'name': 'proute'}).id,
})
self.assertFalse(child.partner_id)
parent.write({'partner_id': self.user.partner_id.id})
self.assertNotEqual(child.partner_id, parent.partner_id)
@@ -138,7 +143,7 @@ class TestNestedTaskUpdate(TransactionCase):
def test_write_sale_line_id_on_parent_dont_write_on_child_if_other_partner(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'project_id': self.project.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'partner_id': self.user.partner_id.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'partner_id': self.user.partner_id.id, 'project_id': self.project.id})
self.assertFalse(child.sale_line_id)
parent.write({'sale_line_id': self.order_line.id})
self.assertFalse(child.sale_line_id)
@@ -150,15 +155,15 @@ class TestNestedTaskUpdate(TransactionCase):
#----------------------------------
def test_linking_user_id_on_parent_dont_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)]})
child = self.env['project.task'].create({'name': 'child', 'user_ids': False})
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)], 'project_id': self.project.id})
child = self.env['project.task'].create({'name': 'child', 'user_ids': False, 'project_id': self.project.id})
self.assertFalse(child.user_ids)
child.write({'parent_id': parent.id})
self.assertFalse(child.user_ids)
def test_linking_partner_id_on_parent_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.user.partner_id.id, 'project_id': self.project.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': False})
child = self.env['project.task'].create({'name': 'child', 'partner_id': False, 'project_id': self.project.id})
self.assertFalse(child.partner_id)
child.write({'parent_id': parent.id, 'project_id': self.project.id})
self.assertEqual(child.partner_id, self.user.partner_id)
@@ -223,26 +228,21 @@ class TestNestedTaskUpdate(TransactionCase):
'project_id': self.project.id,
'child_ids': [
Command.create({
'name': 'Subtask with project set',
'name': 'Subtask 1',
'project_id': self.project.id,
}),
Command.create({
'name': 'Subtask without any project set',
'name': 'Subtask 2',
'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)
self.assertTrue(all((parent + parent._get_all_subtasks()).mapped('allow_billable')))
subtask2 = parent.child_ids.filtered(lambda t: t.name == 'Subtask 2')
subsubtask = subtask2.child_ids
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)
subtask2.project_id = project_non_billable
self.assertFalse(subtask2.allow_billable)
self.assertFalse(subsubtask.allow_billable)
@@ -79,7 +79,7 @@
</span>
</div>
</button>
<button
<button
class="oe_stat_button"
type="object"
name="action_view_sos"
@@ -204,7 +204,7 @@
'|',
'|',
('allow_billable', '=', False),
('project_root_id', '=', False),
('project_id', '=', False),
('sale_line_id', '=', False),
('partner_id', '=', False),
],
@@ -229,7 +229,7 @@
'|',
'|',
('allow_billable', '=', False),
('project_root_id', '=', False),
('project_id', '=', False),
('partner_id', '=', False),
],
}"
+2 -2
View File
@@ -537,10 +537,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_root_id.pricing_type")
pricing_type = fields.Selection(related="project_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_root_id.timesheet_product_id")
timesheet_product_id = fields.Many2one(related="project_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")
@@ -262,9 +262,10 @@ class TestProjectBilling(TestCommonSaleTimesheet):
self.assertEqual(self.project_employee_rate_manager.project_id, timesheet1.project_id, "The timesheet should be linked to the project of the map entry")
# create a subtask
subtask = Task.with_context(default_project_id=self.project_subtask.id).create({
subtask = Task.create({
'name': 'first subtask task',
'parent_id': task.id,
'project_id': self.project_subtask.id,
})
self.assertFalse(subtask.allow_billable, "Subtask in non billable project should be non billable too")
@@ -518,15 +518,15 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
self.assertEqual(timesheet_count2, 1, "One timesheet in project_template")
self.assertEqual(len(task.timesheet_ids), 1, "The timesheet should be linked to task")
# change project of task, as the timesheet is not yet invoiced, the timesheet will change his project
# change project of task, it has no impact on timesheet. never.
task.write({
'project_id': self.project_global.id
})
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_global.id)])
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_template.id)])
self.assertEqual(timesheet_count1, 1, "One timesheet in project_global")
self.assertEqual(timesheet_count2, 0, "No timesheet in project_template")
self.assertEqual(timesheet_count1, 0, "No timesheet in project_global")
self.assertEqual(timesheet_count2, 1, "One timesheet in project_template")
self.assertEqual(len(task.timesheet_ids), 1, "The timesheet still should be linked to task")
# Create an invoice
@@ -550,9 +550,9 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'unit_amount': 6,
})
self.assertEqual(Timesheet.search_count([('project_id', '=', self.project_global.id)]), 2, "2 timesheets in project_global")
self.assertEqual(Timesheet.search_count([('project_id', '=', self.project_global.id)]), 1, "1 timesheets in project_global")
# change project of task, the timesheet not yet invoiced will change its project. The timesheet already invoiced will not change his project.
# change project of task, it has no impact on timesheet. never.
task.write({
'project_id': self.project_template.id
})